diff --git a/briar-android/build.gradle b/briar-android/build.gradle index 6d2fb419e..34040fa1f 100644 --- a/briar-android/build.gradle +++ b/briar-android/build.gradle @@ -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' 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 349e84c82..2e2c4533a 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 @@ -7,7 +7,9 @@ import android.os.Bundle; import android.os.Parcelable; import android.transition.Slide; import android.transition.Transition; +import android.util.Log; import android.util.SparseArray; +import android.view.ActionMode; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; @@ -98,13 +100,20 @@ 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; import static android.os.Build.VERSION.SDK_INT; import static android.view.Gravity.RIGHT; +import static android.widget.Toast.LENGTH_LONG; import static android.widget.Toast.LENGTH_SHORT; import static androidx.core.app.ActivityOptionsCompat.makeSceneTransitionAnimation; import static androidx.core.view.ViewCompat.setTransitionName; @@ -120,6 +129,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 +147,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 +204,11 @@ public class ConversationActivity extends BriarActivity private LinearLayoutManager layoutManager; private TextInputView textInputView; private TextSendController sendController; + private SelectionTracker tracker; @Nullable private Parcelable layoutManagerState; + @Nullable + private ActionMode actionMode; private volatile ContactId contactId; @@ -257,6 +270,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 +362,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 +427,84 @@ 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) { + Log.e("TEST", "selected: " + getSelection()); + Toast.makeText(this, "Not yet implemented", LENGTH_LONG).show(); + 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 observer = new SelectionObserver() { + @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 getSelection() { + Selection selection = tracker.getSelection(); + List 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)) { diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationAdapter.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationAdapter.java index 13bde921a..1e7065df7 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationAdapter.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationAdapter.java @@ -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 @@ -27,6 +29,8 @@ class ConversationAdapter private ConversationListener listener; private final RecycledViewPool imageViewPool; private final ImageItemDecoration imageItemDecoration; + @Nullable + private SelectionTracker 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 tracker) { + this.tracker = tracker; + } + @Nullable ConversationItem getLastItem() { if (items.size() > 0) { diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItem.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItem.java index f70ce35bc..a0cd5d54b 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItem.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItem.java @@ -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; } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemDetailsLookup.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemDetailsLookup.java new file mode 100644 index 000000000..021c769c2 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemDetailsLookup.java @@ -0,0 +1,53 @@ +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 { + + private final RecyclerView recyclerView; + + ConversationItemDetailsLookup(RecyclerView recyclerView) { + this.recyclerView = recyclerView; + } + + @Nullable + @Override + public ItemDetails 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() { + @Override + public int getPosition() { + return pos; + } + + @Override + public String getSelectionKey() { + return id; + } + }; + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemKeyProvider.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemKeyProvider.java new file mode 100644 index 000000000..8e611400c --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemKeyProvider.java @@ -0,0 +1,29 @@ +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 { + + 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); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemViewHolder.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemViewHolder.java index 77b1b9156..2c7e62b80 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemViewHolder.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationItemViewHolder.java @@ -25,6 +25,8 @@ abstract class ConversationItemViewHolder extends ViewHolder { private final OutItemViewHolder outViewHolder; private final TextView text; protected final TextView time; + @Nullable + private String itemKey = null; ConversationItemViewHolder(View v, ConversationListener listener, boolean isIncoming) { @@ -37,7 +39,9 @@ abstract class ConversationItemViewHolder extends ViewHolder { } @CallSuper - void bind(ConversationItem item) { + void bind(ConversationItem item, boolean selected) { + itemKey = item.getKey(); + if (item.getText() != null) { text.setText(trim(item.getText())); } @@ -52,4 +56,9 @@ abstract class ConversationItemViewHolder extends ViewHolder { return outViewHolder == null; } + @Nullable + String getItemKey() { + return itemKey; + } + } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageViewHolder.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageViewHolder.java index 4d5f11561..626979ceb 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageViewHolder.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationMessageViewHolder.java @@ -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()) { diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationNoticeViewHolder.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationNoticeViewHolder.java index 1e70f0560..2244b7ca8 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationNoticeViewHolder.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationNoticeViewHolder.java @@ -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)) { diff --git a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationRequestViewHolder.java b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationRequestViewHolder.java index 3792ee0dc..d5f578339 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationRequestViewHolder.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/conversation/ConversationRequestViewHolder.java @@ -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); diff --git a/briar-android/src/main/res/menu/conversation_message_actions.xml b/briar-android/src/main/res/menu/conversation_message_actions.xml new file mode 100644 index 000000000..caf2d8ad9 --- /dev/null +++ b/briar-android/src/main/res/menu/conversation_message_actions.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/briar-android/src/main/res/values/themes.xml b/briar-android/src/main/res/values/themes.xml index e47e9d233..211fe6ebc 100644 --- a/briar-android/src/main/res/values/themes.xml +++ b/briar-android/src/main/res/values/themes.xml @@ -8,6 +8,7 @@ @color/briar_text_link @color/window_background @style/ActivityAnimation + true @style/BriarDialogTheme.Neutral @style/PreferenceThemeOverlay.v14.Material diff --git a/briar-android/witness.gradle b/briar-android/witness.gradle index 5569b2788..5824b4577 100644 --- a/briar-android/witness.gradle +++ b/briar-android/witness.gradle @@ -36,6 +36,7 @@ dependencyVerification { 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0:localbroadcastmanager-1.0.0.aar:e71c328ceef5c4a7d76f2d86df1b65d65fe2acf868b1a4efd84a3f34336186d8', 'androidx.preference:preference:1.1.0:preference-1.1.0.aar:6cf1a099b03b3254041b04701841865b2708c0b546b9036c8b0dada0aa59de57', 'androidx.print:print:1.0.0:print-1.0.0.aar:1d5c7f3135a1bba661fc373fd72e11eb0a4adbb3396787826dd8e4190d5d9edd', + 'androidx.recyclerview:recyclerview-selection:1.0.0:recyclerview-selection-1.0.0.aar:db3db72af8cfcd701ab6ed7a080327a2e993e3a429f5efb8f0c108bff38c4922', 'androidx.recyclerview:recyclerview:1.1.0-beta04:recyclerview-1.1.0-beta04.aar:c3c8310eb058a660a845cf814a54f56e6f448b7855f9ccea2a5ad18189f57f69', 'androidx.savedstate:savedstate:1.0.0:savedstate-1.0.0.aar:2510a5619c37579c9ce1a04574faaf323cd0ffe2fc4e20fa8f8f01e5bb402e83', 'androidx.test.espresso:espresso-contrib:3.2.0:espresso-contrib-3.2.0.aar:9e43811e5845e84f2964f0032fd50cd11dca3dc8d3b0703626dd12d50433bb35',