Commit 11c43dc7 authored by akwizgran's avatar akwizgran

Merge branch '1628-multi-select' into 'master'

Multi-select conversion messages (to delete)

Closes #1628

See merge request !1179
parents ed66a470 497ab38b
Pipeline #3905 passed with stage
in 11 minutes and 25 seconds
......@@ -97,6 +97,7 @@ dependencies {
implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
implementation 'com.google.android.material:material:1.1.0-beta01'
implementation 'androidx.recyclerview:recyclerview-selection:1.0.0'
implementation 'ch.acra:acra:4.11'
implementation 'info.guardianproject.panic:panic:1.0'
......
......@@ -8,6 +8,7 @@ import android.os.Parcelable;
import android.transition.Slide;
import android.transition.Transition;
import android.util.SparseArray;
import android.view.ActionMode;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
......@@ -98,7 +99,13 @@ import androidx.core.content.ContextCompat;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.lifecycle.ViewModelProviders;
import androidx.recyclerview.selection.Selection;
import androidx.recyclerview.selection.SelectionPredicates;
import androidx.recyclerview.selection.SelectionTracker;
import androidx.recyclerview.selection.SelectionTracker.SelectionObserver;
import androidx.recyclerview.selection.StorageStrategy;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import de.hdodenhof.circleimageview.CircleImageView;
import im.delight.android.identicons.IdenticonDrawable;
import uk.co.samuelwall.materialtaptargetprompt.MaterialTapTargetPrompt;
......@@ -120,6 +127,7 @@ 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;
import static org.briarproject.bramble.util.StringUtils.fromHexString;
import static org.briarproject.bramble.util.StringUtils.isNullOrEmpty;
import static org.briarproject.briar.android.activity.RequestCodes.REQUEST_ATTACH_IMAGE;
import static org.briarproject.briar.android.activity.RequestCodes.REQUEST_INTRODUCTION;
......@@ -137,7 +145,7 @@ import static org.briarproject.briar.api.messaging.MessagingConstants.MAX_PRIVAT
@ParametersNotNullByDefault
public class ConversationActivity extends BriarActivity
implements EventListener, ConversationListener, TextCache,
AttachmentCache, AttachmentListener {
AttachmentCache, AttachmentListener, ActionMode.Callback {
public static final String CONTACT_ID = "briar.CONTACT_ID";
......@@ -194,8 +202,11 @@ public class ConversationActivity extends BriarActivity
private LinearLayoutManager layoutManager;
private TextInputView textInputView;
private TextSendController sendController;
private SelectionTracker<String> tracker;
@Nullable
private Parcelable layoutManagerState;
@Nullable
private ActionMode actionMode;
private volatile ContactId contactId;
......@@ -257,6 +268,9 @@ public class ConversationActivity extends BriarActivity
ConversationScrollListener scrollListener =
new ConversationScrollListener(adapter, viewModel);
list.getRecyclerView().addOnScrollListener(scrollListener);
if (featureFlags.shouldEnablePrivateMessageDeletion()) {
addSelectionTracker();
}
textInputView = findViewById(R.id.text_input_container);
if (featureFlags.shouldEnableImageAttachments()) {
......@@ -346,12 +360,14 @@ public class ConversationActivity extends BriarActivity
layoutManagerState = layoutManager.onSaveInstanceState();
outState.putParcelable("layoutManager", layoutManagerState);
}
if (tracker != null) tracker.onSaveInstanceState(outState);
}
@Override
protected void onRestoreInstanceState(Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
layoutManagerState = savedInstanceState.getParcelable("layoutManager");
if (tracker != null) tracker.onRestoreInstanceState(savedInstanceState);
}
@Override
......@@ -409,6 +425,83 @@ public class ConversationActivity extends BriarActivity
}
}
@Override
public boolean onCreateActionMode(ActionMode mode, Menu menu) {
MenuInflater inflater = mode.getMenuInflater();
inflater.inflate(R.menu.conversation_message_actions, menu);
return true;
}
@Override
public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
return false; // no update needed
}
@Override
public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
if (item.getItemId() == R.id.action_delete) {
deleteSelectedMessages();
return true;
}
return false;
}
@Override
public void onDestroyActionMode(ActionMode mode) {
tracker.clearSelection();
actionMode = null;
}
private void addSelectionTracker() {
RecyclerView recyclerView = list.getRecyclerView();
if (recyclerView.getAdapter() != adapter)
throw new IllegalStateException();
tracker = new SelectionTracker.Builder<>(
"conversationSelection",
recyclerView,
new ConversationItemKeyProvider(adapter),
new ConversationItemDetailsLookup(recyclerView),
StorageStrategy.createStringStorage()
).withSelectionPredicate(
SelectionPredicates.createSelectAnything()
).build();
SelectionObserver<String> observer = new SelectionObserver<String>() {
@Override
public void onItemStateChanged(String key, boolean selected) {
if (selected && actionMode == null) {
actionMode = startActionMode(ConversationActivity.this);
updateActionModeTitle();
} else if (actionMode != null) {
if (selected || tracker.hasSelection()) {
updateActionModeTitle();
} else {
actionMode.finish();
}
}
}
};
tracker.addObserver(observer);
adapter.setSelectionTracker(tracker);
}
private void updateActionModeTitle() {
if (actionMode == null) throw new IllegalStateException();
String title = String.valueOf(tracker.getSelection().size());
actionMode.setTitle(title);
}
private Collection<MessageId> getSelection() {
Selection<String> selection = tracker.getSelection();
List<MessageId> messages = new ArrayList<>(selection.size());
for (String str : selection) {
MessageId id = new MessageId(fromHexString(str));
messages.add(id);
}
return messages;
}
@UiThread
private void displayContactOnlineStatus() {
if (connectionRegistry.isConnected(contactId)) {
......@@ -766,7 +859,24 @@ public class ConversationActivity extends BriarActivity
try {
boolean allDeleted =
conversationManager.deleteAllMessages(contactId);
reloadConversationAfterDeletingAllMessages(allDeleted);
reloadConversationAfterDeletingMessages(allDeleted);
} catch (DbException e) {
logException(LOG, WARNING, e);
runOnUiThreadUnlessDestroyed(() -> list.showData());
}
});
}
private void deleteSelectedMessages() {
list.showProgressBar();
Collection<MessageId> selected = getSelection();
// close action mode only after getting the selection
if (actionMode != null) actionMode.finish();
runOnDbThread(() -> {
try {
boolean allDeleted =
conversationManager.deleteMessages(contactId, selected);
reloadConversationAfterDeletingMessages(allDeleted);
} catch (DbException e) {
logException(LOG, WARNING, e);
runOnUiThreadUnlessDestroyed(() -> list.showData());
......@@ -774,10 +884,11 @@ public class ConversationActivity extends BriarActivity
});
}
private void reloadConversationAfterDeletingAllMessages(
private void reloadConversationAfterDeletingMessages(
boolean allDeleted) {
runOnUiThreadUnlessDestroyed(() -> {
adapter.clear();
list.showProgressBar(); // otherwise clearing shows empty state
loadMessages();
if (!allDeleted) showNotAllDeletedDialog();
});
......
......@@ -13,12 +13,14 @@ import org.briarproject.briar.R;
import org.briarproject.briar.android.util.BriarAdapter;
import org.briarproject.briar.android.util.ItemReturningAdapter;
import javax.annotation.Nullable;
import androidx.annotation.LayoutRes;
import androidx.annotation.Nullable;
import androidx.recyclerview.selection.SelectionTracker;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView.RecycledViewPool;
import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;
@NotNullByDefault
class ConversationAdapter
extends BriarAdapter<ConversationItem, ConversationItemViewHolder>
......@@ -27,6 +29,8 @@ class ConversationAdapter
private ConversationListener listener;
private final RecycledViewPool imageViewPool;
private final ImageItemDecoration imageItemDecoration;
@Nullable
private SelectionTracker<String> tracker = null;
ConversationAdapter(Context ctx,
ConversationListener conversationListener) {
......@@ -45,6 +49,17 @@ class ConversationAdapter
return item.getLayout();
}
String getItemKey(int position) {
return items.get(position).getKey();
}
int getPositionOfKey(String key) {
for (int i = 0; i < items.size(); i++) {
if (key.equals(items.get(i).getKey())) return i;
}
return NO_POSITION;
}
@Override
public ConversationItemViewHolder onCreateViewHolder(ViewGroup viewGroup,
@LayoutRes int type) {
......@@ -71,7 +86,8 @@ class ConversationAdapter
@Override
public void onBindViewHolder(ConversationItemViewHolder ui, int position) {
ConversationItem item = items.get(position);
ui.bind(item);
boolean selected = tracker != null && tracker.isSelected(item.getKey());
ui.bind(item, selected);
}
@Override
......@@ -91,6 +107,10 @@ class ConversationAdapter
return c1.equals(c2);
}
void setSelectionTracker(SelectionTracker<String> tracker) {
this.tracker = tracker;
}
@Nullable
ConversationItem getLastItem() {
if (items.size() > 0) {
......
......@@ -10,6 +10,8 @@ import javax.annotation.concurrent.NotThreadSafe;
import androidx.annotation.LayoutRes;
import static org.briarproject.bramble.util.StringUtils.toHexString;
@NotThreadSafe
@NotNullByDefault
abstract class ConversationItem {
......@@ -45,6 +47,10 @@ abstract class ConversationItem {
return id;
}
String getKey() {
return toHexString(id.getBytes());
}
GroupId getGroupId() {
return groupId;
}
......
package org.briarproject.briar.android.conversation;
import android.view.MotionEvent;
import android.view.View;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import androidx.annotation.Nullable;
import androidx.recyclerview.selection.ItemDetailsLookup;
import androidx.recyclerview.widget.RecyclerView;
import static androidx.recyclerview.widget.RecyclerView.NO_POSITION;
@NotNullByDefault
class ConversationItemDetailsLookup extends ItemDetailsLookup<String > {
private final RecyclerView recyclerView;
ConversationItemDetailsLookup(RecyclerView recyclerView) {
this.recyclerView = recyclerView;
}
@Nullable
@Override
public ItemDetails<String> getItemDetails(MotionEvent e) {
// find view that corresponds to MotionEvent
View view = recyclerView.findChildViewUnder(e.getX(), e.getY());
if (view == null) return null;
// get position
int pos = recyclerView.getChildAdapterPosition(view);
if (pos == NO_POSITION) return null;
// get key ID
ConversationItemViewHolder holder =
(ConversationItemViewHolder) recyclerView.getChildViewHolder(view);
String id = holder.getItemKey();
if (id == null) return null;
return new ItemDetails<String>() {
@Override
public int getPosition() {
return pos;
}
@Override
public String getSelectionKey() {
return id;
}
};
}
}
package org.briarproject.briar.android.conversation;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import androidx.annotation.Nullable;
import androidx.recyclerview.selection.ItemKeyProvider;
@NotNullByDefault
class ConversationItemKeyProvider extends ItemKeyProvider<String> {
private final ConversationAdapter adapter;
protected ConversationItemKeyProvider(ConversationAdapter adapter) {
super(SCOPE_MAPPED);
this.adapter = adapter;
}
@Nullable
@Override
public String getKey(int position) {
return adapter.getItemKey(position);
}
@Override
public int getPosition(String key) {
return adapter.getPositionOfKey(key);
}
}
......@@ -20,24 +20,31 @@ import static org.briarproject.briar.android.util.UiUtils.formatDate;
abstract class ConversationItemViewHolder extends ViewHolder {
protected final ConversationListener listener;
private final View root;
protected final ConstraintLayout layout;
@Nullable
private final OutItemViewHolder outViewHolder;
private final TextView text;
protected final TextView time;
@Nullable
private String itemKey = null;
ConversationItemViewHolder(View v, ConversationListener listener,
boolean isIncoming) {
super(v);
this.listener = listener;
this.outViewHolder = isIncoming ? null : new OutItemViewHolder(v);
root = v;
layout = v.findViewById(R.id.layout);
text = v.findViewById(R.id.text);
time = v.findViewById(R.id.time);
}
@CallSuper
void bind(ConversationItem item) {
void bind(ConversationItem item, boolean selected) {
itemKey = item.getKey();
root.setActivated(selected);
if (item.getText() != null) {
text.setText(trim(item.getText()));
}
......@@ -52,4 +59,9 @@ abstract class ConversationItemViewHolder extends ViewHolder {
return outViewHolder == null;
}
@Nullable
String getItemKey() {
return itemKey;
}
}
......@@ -44,8 +44,8 @@ class ConversationMessageViewHolder extends ConversationItemViewHolder {
timeColorBubble = getColor(v.getContext(), R.color.briar_white);
// clone constraint sets from layout files
textConstraints
.clone(v.getContext(), R.layout.list_item_conversation_msg_in);
textConstraints.clone(v.getContext(),
R.layout.list_item_conversation_msg_in_content);
imageConstraints.clone(v.getContext(),
R.layout.list_item_conversation_msg_image);
imageTextConstraints.clone(v.getContext(),
......@@ -61,8 +61,8 @@ class ConversationMessageViewHolder extends ConversationItemViewHolder {
}
@Override
void bind(ConversationItem conversationItem) {
super.bind(conversationItem);
void bind(ConversationItem conversationItem, boolean selected) {
super.bind(conversationItem, selected);
ConversationMessageItem item =
(ConversationMessageItem) conversationItem;
if (item.getAttachments().isEmpty()) {
......
......@@ -28,9 +28,9 @@ class ConversationNoticeViewHolder extends ConversationItemViewHolder {
@Override
@CallSuper
void bind(ConversationItem item) {
void bind(ConversationItem item, boolean selected) {
ConversationNoticeItem notice = (ConversationNoticeItem) item;
super.bind(notice);
super.bind(notice, selected);
String text = notice.getMsgText();
if (isNullOrEmpty(text)) {
......
......@@ -26,9 +26,9 @@ class ConversationRequestViewHolder extends ConversationNoticeViewHolder {
}
@Override
void bind(ConversationItem item) {
void bind(ConversationItem item, boolean selected) {
ConversationRequestItem request = (ConversationRequestItem) item;
super.bind(request);
super.bind(request, selected);
if (request.wasAnswered() && request.canBeOpened()) {
acceptButton.setVisibility(VISIBLE);
......
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/msg_selected_background" android:state_activated="true" />
</selector>
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layout"
android:layout_width="wrap_content"
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/message_bubble_margin_tail"
android:layout_marginLeft="@dimen/message_bubble_margin_tail"
android:layout_marginTop="@dimen/message_bubble_margin"
android:layout_marginEnd="@dimen/message_bubble_margin_non_tail"
android:layout_marginRight="@dimen/message_bubble_margin_non_tail"
android:layout_marginBottom="@dimen/message_bubble_margin"
android:background="@drawable/msg_in"
android:elevation="@dimen/message_bubble_elevation">
android:background="@drawable/list_item_background_selectable">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/imageList"
android:layout_width="@dimen/message_bubble_image_default"
android:layout_height="@dimen/message_bubble_image_default"
android:orientation="vertical"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager"
app:layout_constraintBottom_toTopOf="@+id/text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:spanCount="2"
tools:ignore="ContentDescription"
tools:listitem="@layout/list_item_image" />
<!--
We need to wrap the actual layout, because
* we want to clone the ConstraintLayout's constraints in the ViewHolder
* we want to have a selectable frame around the message bubble
-->
<include layout="@layout/list_item_conversation_msg_in_content" />
<com.vanniktech.emoji.EmojiTextView
android:id="@+id/text"
style="@style/TextMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/message_bubble_padding_sides_inner"
android:layout_marginTop="@dimen/message_bubble_padding_top_inner"
android:layout_marginRight="@dimen/message_bubble_padding_sides_inner"
android:layout_marginBottom="@dimen/message_bubble_padding_bottom_inner"
android:textColor="?android:attr/textColorPrimary"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toTopOf="@+id/statusLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageList"
tools:text="The text of a message which can sometimes be a bit longer as well" />
<LinearLayout
android:id="@+id/statusLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/message_bubble_padding_sides_inner"
android:layout_marginRight="@dimen/message_bubble_padding_sides_inner"
android:layout_marginBottom="@dimen/message_bubble_padding_bottom_inner"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text">
<TextView
android:id="@+id/time"
style="@style/TextMessage.Timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Dec 24, 13:37" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/message_bubble_margin_tail"
android:layout_marginLeft="@dimen/message_bubble_margin_tail"
android:layout_marginTop="@dimen/message_bubble_margin"
android:layout_marginEnd="@dimen/message_bubble_margin_non_tail"
android:layout_marginRight="@dimen/message_bubble_margin_non_tail"
android:layout_marginBottom="@dimen/message_bubble_margin"
android:background="@drawable/msg_in"
android:elevation="@dimen/message_bubble_elevation">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/imageList"
android:layout_width="@dimen/message_bubble_image_default"
android:layout_height="@dimen/message_bubble_image_default"
android:orientation="vertical"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.StaggeredGridLayoutManager"
app:layout_constraintBottom_toTopOf="@+id/text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:spanCount="2"
tools:ignore="ContentDescription"
tools:listitem="@layout/list_item_image" />
<com.vanniktech.emoji.EmojiTextView
android:id="@+id/text"
style="@style/TextMessage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/message_bubble_padding_sides_inner"
android:layout_marginTop="@dimen/message_bubble_padding_top_inner"
android:layout_marginRight="@dimen/message_bubble_padding_sides_inner"
android:layout_marginBottom="@dimen/message_bubble_padding_bottom_inner"
android:textColor="?android:attr/textColorPrimary"
app:layout_constrainedWidth="true"
app:layout_constraintBottom_toTopOf="@+id/statusLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/imageList"
tools:text="The text of a message which can sometimes be a bit longer as well" />
<LinearLayout
android:id="@+id/statusLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="@dimen/message_bubble_padding_sides_inner"
android:layout_marginRight="@dimen/message_bubble_padding_sides_inner"
android:layout_marginBottom="@dimen/message_bubble_padding_bottom_inner"
android:orientation="horizontal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/text">
<TextView
android:id="@+id/time"
style="@style/TextMessage.Timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="Dec 24, 13:37" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<?xml version="1.0" encoding="utf-8"?><!-- This is needed to right-align message bubble in RecyclerView -->
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:background="@drawable/list_item_background_selectable">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/layout"
......
......@@ -4,8 +4,9 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/message_bubble_margin"
android:orientation="vertical">
android:background="@drawable/list_item_background_selectable"
android:orientation="vertical"
android:paddingTop="@dimen/message_bubble_margin">
<com.vanniktech.emoji.EmojiTextView
android:id="@+id/msgText"
......
......@@ -4,8 +4,9 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/message_bubble_margin"
android:orientation="vertical">
android:background="@drawable/list_item_background_selectable"
android:orientation="vertical"
android:paddingTop="@dimen/message_bubble_margin">
<com.vanniktech.emoji.EmojiTextView
android:id="@+id/msgText"
......
......@@ -4,8 +4,9 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/message_bubble_margin"
android:orientation="vertical">