diff --git a/build.gradle b/build.gradle index 72d2cbe280b9231cf530e76574e1531b527e17ed..79c4737acb03a5bfed8adcf642c1de5a634333c9 100644 --- a/build.gradle +++ b/build.gradle @@ -98,6 +98,7 @@ dependencies { implementation 'com.github.ipld:java-cid:v1.3.1' implementation 'com.splitwise:tokenautocomplete:3.0.2' implementation 'me.saket:better-link-movement-method:2.2.0' + implementation 'com.github.singpolyma:android-identicons:master-SNAPSHOT' implementation 'org.snikket:webrtc-android:107.0.0' // INSERT } diff --git a/src/cheogram/res/values/strings.xml b/src/cheogram/res/values/strings.xml index ed2152ab9b54fdf514f1432163029b5e24ec2f41..d4bc883598daf9b0b2d61f4d067b5ab49140155c 100644 --- a/src/cheogram/res/values/strings.xml +++ b/src/cheogram/res/values/strings.xml @@ -25,6 +25,7 @@ Go OLED Black Invite to Chat + Show only this thread Use Phone Accounts for Incoming Calls Incoming calls from phone numbers may ring with your system dialler instead of this app\'s notification settings diff --git a/src/cheogram/res/values/themes.xml b/src/cheogram/res/values/themes.xml index c7d21f641b9163938934e6958c73c2f4317a9810..55494cea1a2b4a4b56220eeabb5fe28ea84b7f18 100644 --- a/src/cheogram/res/values/themes.xml +++ b/src/cheogram/res/values/themes.xml @@ -121,6 +121,7 @@ @drawable/ic_help_white_24dp @drawable/ic_question_answer_white_24dp @drawable/ic_lock_open_white_24dp + @drawable/ic_lock_black_18dp @drawable/ic_settings_black_24dp @drawable/ic_share_white_24dp @drawable/ic_cloud_download_white_24dp @@ -275,6 +276,7 @@ @drawable/ic_help_white_24dp @drawable/ic_question_answer_white_24dp @drawable/ic_lock_open_white_24dp + @drawable/ic_lock_white_18dp @drawable/ic_settings_white_24dp @drawable/ic_share_white_24dp @drawable/ic_cloud_download_white_24dp diff --git a/src/main/java/eu/siacs/conversations/entities/Conversation.java b/src/main/java/eu/siacs/conversations/entities/Conversation.java index b9bdd21b2d2ecc5721b15ce66b1dfcf492904389..e7e098815eaf1c1541c634fb11f945770588f2e4 100644 --- a/src/main/java/eu/siacs/conversations/entities/Conversation.java +++ b/src/main/java/eu/siacs/conversations/entities/Conversation.java @@ -148,6 +148,9 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl private String mFirstMamReference = null; protected int mCurrentTab = -1; protected ConversationPagerAdapter pagerAdapter = new ConversationPagerAdapter(); + protected Element thread = null; + protected boolean lockThread = false; + protected boolean userSelectedThread = false; public Conversation(final String name, final Account account, final Jid contactJid, final int mode) { @@ -529,7 +532,8 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl messages.addAll(this.messages); } for (Iterator iterator = messages.iterator(); iterator.hasNext(); ) { - if (iterator.next().wasMergedIntoPrevious()) { + Message m = iterator.next(); + if (m.wasMergedIntoPrevious() || (getLockThread() && (m.getThread() == null || !m.getThread().getContent().equals(getThread().getContent())))) { iterator.remove(); } } @@ -628,6 +632,31 @@ public class Conversation extends AbstractEntity implements Blockable, Comparabl this.draftMessage = draftMessage; } + public Element getThread() { + return this.thread; + } + + public void setThread(Element thread) { + this.thread = thread; + } + + public void setLockThread(boolean flag) { + this.lockThread = flag; + if (flag) setUserSelectedThread(true); + } + + public boolean getLockThread() { + return this.lockThread; + } + + public void setUserSelectedThread(boolean flag) { + this.userSelectedThread = flag; + } + + public boolean getUserSelectedThread() { + return this.userSelectedThread; + } + public boolean isRead() { synchronized (this.messages) { for(final Message message : Lists.reverse(this.messages)) { diff --git a/src/main/java/eu/siacs/conversations/entities/Message.java b/src/main/java/eu/siacs/conversations/entities/Message.java index e88bf3265506aa2815da29217b4e3c7903e8de5f..47fde1f5051683508e46a1bc2394df2730acf528 100644 --- a/src/main/java/eu/siacs/conversations/entities/Message.java +++ b/src/main/java/eu/siacs/conversations/entities/Message.java @@ -418,6 +418,23 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable this.subject = subject; } + public Element getThread() { + if (this.payloads == null) return null; + + for (Element el : this.payloads) { + if (el.getName().equals("thread") && el.getNamespace().equals("jabber:client")) { + return el; + } + } + + return null; + } + + public void setThread(Element thread) { + payloads.removeIf(el -> el.getName().equals("thread") && el.getNamespace().equals("jabber:client")); + addPayload(thread); + } + public void setMucUser(MucOptions.User user) { this.user = new WeakReference<>(user); } @@ -907,9 +924,15 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable } public void addPayload(Element el) { + if (el == null) return; + this.payloads.add(el); } + public List getPayloads() { + return new ArrayList<>(this.payloads); + } + public Element getHtml() { if (this.payloads == null) return null; @@ -920,7 +943,7 @@ public class Message extends AbstractEntity implements AvatarService.Avatarable } return null; - } + } public List getCommands() { if (this.payloads == null) return null; diff --git a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java index 4b055e15883131ab61afff17482c7b4dbdec13d3..90a312204094c58cc2bda1baf3e27ed4aa7f25bf 100644 --- a/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java +++ b/src/main/java/eu/siacs/conversations/generator/MessageGenerator.java @@ -62,6 +62,9 @@ public class MessageGenerator extends AbstractGenerator { if (message.edited()) { packet.addChild("replace", "urn:xmpp:message-correct:0").setAttribute("id", message.getEditedIdWireFormat()); } + for (Element el : message.getPayloads()) { + packet.addChild(el); + } return packet; } diff --git a/src/main/java/eu/siacs/conversations/parser/MessageParser.java b/src/main/java/eu/siacs/conversations/parser/MessageParser.java index 952d3d83efada6f51e6a5cb657c42589e0555e23..a6f924b28cb7e903f872c88285dfb18395fc14e0 100644 --- a/src/main/java/eu/siacs/conversations/parser/MessageParser.java +++ b/src/main/java/eu/siacs/conversations/parser/MessageParser.java @@ -610,6 +610,10 @@ public class MessageParser extends AbstractParser implements OnMessagePacketRece if (el.getName().equals("query") && el.getNamespace().equals("http://jabber.org/protocol/disco#items") && el.getAttribute("node").equals("http://jabber.org/protocol/commands")) { message.addPayload(el); } + if (el.getName().equals("thread") && (el.getNamespace() == null || el.getNamespace().equals("jabber:client"))) { + el.setAttribute("xmlns", "jabber:client"); + message.addPayload(el); + } } if (conversationMultiMode) { message.setMucUser(conversation.getMucOptions().findUserByFullJid(counterpart)); diff --git a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java index 6091a59a61161d93429ca3805414ebfac513dc13..7eac0d2285d01bef1dc06c879e4ffc00562e99d0 100644 --- a/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java +++ b/src/main/java/eu/siacs/conversations/services/XmppConnectionService.java @@ -568,6 +568,7 @@ public class XmppConnectionService extends Service { encryption = Message.ENCRYPTION_DECRYPTED; } Message message = new Message(conversation, uri.toString(), encryption); + message.setThread(conversation.getThread()); Message.configurePrivateMessage(message); if (encryption == Message.ENCRYPTION_DECRYPTED) { getPgpEngine().encrypt(message, callback); @@ -584,6 +585,7 @@ public class XmppConnectionService extends Service { } else { message = new Message(conversation, "", conversation.getNextEncryption()); } + message.setThread(conversation.getThread()); if (!Message.configurePrivateFileMessage(message)) { message.setCounterpart(conversation.getNextCounterpart()); message.setType(Message.TYPE_FILE); @@ -616,6 +618,7 @@ public class XmppConnectionService extends Service { } else { message = new Message(conversation, "", conversation.getNextEncryption()); } + message.setThread(conversation.getThread()); if (!Message.configurePrivateFileMessage(message)) { message.setCounterpart(conversation.getNextCounterpart()); message.setType(Message.TYPE_IMAGE); @@ -980,6 +983,7 @@ public class XmppConnectionService extends Service { private void directReply(final Conversation conversation, final String body, final String lastMessageUuid, final boolean dismissAfterReply) { final Message inReplyTo = lastMessageUuid == null ? null : conversation.findMessageWithUuid(lastMessageUuid); final Message message = new Message(conversation, body, conversation.getNextEncryption()); + if (inReplyTo != null) message.setThread(inReplyTo.getThread()); if (inReplyTo != null && inReplyTo.isPrivateMessage()) { Message.configurePrivateMessage(message, inReplyTo.getCounterpart()); } diff --git a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java index 42e095d786da861f10e8466c08803d036c699da1..123f154b3456ea7b9011facb49e5c4f040422313 100644 --- a/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java +++ b/src/main/java/eu/siacs/conversations/ui/ConversationFragment.java @@ -264,6 +264,7 @@ public class ConversationFragment extends XmppFragment @Override public void onScrollStateChanged(AbsListView view, int scrollState) { if (AbsListView.OnScrollListener.SCROLL_STATE_IDLE == scrollState) { + updateThreadFromLastMessage(); fireReadEvent(); } } @@ -854,6 +855,7 @@ public class ConversationFragment extends XmppFragment } private void sendMessage() { + conversation.setUserSelectedThread(false); if (mediaPreviewAdapter.hasAttachments()) { commitAttachments(); return; @@ -870,6 +872,7 @@ public class ConversationFragment extends XmppFragment final Message message; if (conversation.getCorrectingMessage() == null) { message = new Message(conversation, body, conversation.getNextEncryption()); + message.setThread(conversation.getThread()); Message.configurePrivateMessage(message); } else { message = conversation.getCorrectingMessage(); @@ -953,6 +956,8 @@ public class ConversationFragment extends XmppFragment this.binding.textinput.setHint(UIHelper.getMessageHint(getActivity(), conversation)); getActivity().invalidateOptionsMenu(); } + + binding.messagesView.post(this::updateThreadFromLastMessage); } public void setupIme() { @@ -1248,6 +1253,33 @@ public class ConversationFragment extends XmppFragment new EditMessageActionModeCallback(this.binding.textinput)); } + messageListAdapter.setOnMessageBoxClicked(message -> { + setThread(message.getThread()); + conversation.setUserSelectedThread(true); + }); + + binding.threadIdenticonLayout.setOnClickListener(v -> { + boolean wasLocked = conversation.getLockThread(); + conversation.setLockThread(false); + if (wasLocked) { + conversation.setUserSelectedThread(false); + refresh(); + updateThreadFromLastMessage(); + } else { + newThread(); + conversation.setUserSelectedThread(true); + } + }); + + binding.threadIdenticonLayout.setOnLongClickListener(v -> { + boolean wasLocked = conversation.getLockThread(); + conversation.setLockThread(false); + setThread(null); + conversation.setUserSelectedThread(true); + if (wasLocked) refresh(); + return true; + }); + return binding.getRoot(); } @@ -1275,9 +1307,25 @@ public class ConversationFragment extends XmppFragment } private void quoteMessage(Message message) { + setThread(message.getThread()); + conversation.setUserSelectedThread(true); quoteText(MessageUtils.prepareQuote(message)); } + private void setThread(Element thread) { + this.conversation.setThread(thread); + binding.threadIdenticon.setAlpha(0f); + binding.threadIdenticonLock.setVisibility(this.conversation.getLockThread() ? View.VISIBLE : View.GONE); + if (thread != null) { + final String threadId = thread.getContent(); + if (threadId != null) { + binding.threadIdenticon.setAlpha(1f); + binding.threadIdenticon.setColor(UIHelper.getColorForName(threadId)); + binding.threadIdenticon.setHash(UIHelper.identiconHash(threadId)); + } + } + } + @Override public void onCreateContextMenu(ContextMenu menu, View v, ContextMenuInfo menuInfo) { // This should cancel any remaining click events that would otherwise trigger links @@ -1327,6 +1375,7 @@ public class ConversationFragment extends XmppFragment MenuItem retryDecryption = menu.findItem(R.id.retry_decryption); MenuItem correctMessage = menu.findItem(R.id.correct_message); MenuItem retractMessage = menu.findItem(R.id.retract_message); + MenuItem onlyThisThread = menu.findItem(R.id.only_this_thread); MenuItem shareWith = menu.findItem(R.id.share_with); MenuItem sendAgain = menu.findItem(R.id.send_again); MenuItem copyUrl = menu.findItem(R.id.copy_url); @@ -1334,6 +1383,7 @@ public class ConversationFragment extends XmppFragment MenuItem cancelTransmission = menu.findItem(R.id.cancel_transmission); MenuItem deleteFile = menu.findItem(R.id.delete_file); MenuItem showErrorMessage = menu.findItem(R.id.show_error_message); + onlyThisThread.setVisible(!conversation.getLockThread() && m.getThread() != null); final boolean unInitiatedButKnownSize = MessageUtils.unInitiatedButKnownSize(m); final boolean showError = m.getStatus() == Message.STATUS_SEND_FAILED @@ -1470,6 +1520,11 @@ public class ConversationFragment extends XmppFragment case R.id.open_with: openWith(selectedMessage); return true; + case R.id.only_this_thread: + conversation.setLockThread(true); + setThread(selectedMessage.getThread()); + refresh(); + return true; default: return super.onContextItemSelected(item); } @@ -2116,7 +2171,29 @@ public class ConversationFragment extends XmppFragment } } + private void newThread() { + Element thread = new Element("thread", "jabber:client"); + thread.setContent(UUID.randomUUID().toString()); + setThread(thread); + } + + private void updateThreadFromLastMessage() { + if (this.conversation != null && !this.conversation.getUserSelectedThread() && TextUtils.isEmpty(binding.textinput.getText())) { + Message message = getLastVisibleMessage(); + if (message == null) { + newThread(); + } else { + setThread(message.getThread()); + } + } + } + private String getLastVisibleMessageUuid() { + Message message = getLastVisibleMessage(); + return message == null ? null : message.getUuid(); + } + + private Message getLastVisibleMessage() { if (binding == null) { return null; } @@ -2140,7 +2217,7 @@ public class ConversationFragment extends XmppFragment while (message.next() != null && message.next().wasMergedIntoPrevious()) { message = message.next(); } - return message.getUuid(); + return message; } } } @@ -3684,13 +3761,9 @@ public class ConversationFragment extends XmppFragment @Override public void onContactPictureClicked(Message message) { - String fingerprint; - if (message.getEncryption() == Message.ENCRYPTION_PGP - || message.getEncryption() == Message.ENCRYPTION_DECRYPTED) { - fingerprint = "pgp"; - } else { - fingerprint = message.getFingerprint(); - } + setThread(message.getThread()); + conversation.setUserSelectedThread(true); + final boolean received = message.getStatus() <= Message.STATUS_RECEIVED; if (received) { if (message.getConversation() instanceof Conversation @@ -3724,15 +3797,8 @@ public class ConversationFragment extends XmppFragment .show(); } } - return; - } else { - if (!message.getContact().isSelf()) { - activity.switchToContactDetails(message.getContact(), fingerprint); - return; - } } } - activity.switchToAccount(message.getConversation().getAccount(), fingerprint); } private Activity requireActivity() { diff --git a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java index 7e0897b271e1004ec7b47a3c1a8c701e7a8c6a96..1e44e08373324f97beea339a7e92eb8fd1d048b8 100644 --- a/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java +++ b/src/main/java/eu/siacs/conversations/ui/adapter/MessageAdapter.java @@ -42,6 +42,8 @@ import com.cheogram.android.BobTransfer; import com.google.common.base.Strings; +import com.lelloman.identicon.view.GithubIdenticonView; + import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; @@ -107,6 +109,7 @@ public class MessageAdapter extends ArrayAdapter { private List highlightedTerm = null; private final DisplayMetrics metrics; private OnContactPictureClicked mOnContactPictureClickedListener; + private OnContactPictureClicked mOnMessageBoxClickedListener; private OnContactPictureLongClicked mOnContactPictureLongClickedListener; private boolean mUseGreenBackground = false; private final boolean mForceNames; @@ -146,6 +149,10 @@ public class MessageAdapter extends ArrayAdapter { this.mOnContactPictureClickedListener = listener; } + public void setOnMessageBoxClicked(OnContactPictureClicked listener) { + this.mOnMessageBoxClickedListener = listener; + } + public Activity getActivity() { return activity; } @@ -717,6 +724,7 @@ public class MessageAdapter extends ArrayAdapter { viewHolder.subject = view.findViewById(R.id.message_subject); viewHolder.indicatorReceived = view.findViewById(R.id.indicator_received); viewHolder.audioPlayer = view.findViewById(R.id.audio_player); + viewHolder.thread_identicon = view.findViewById(R.id.thread_identicon); break; case RECEIVED: view = activity.getLayoutInflater().inflate(R.layout.message_received, parent, false); @@ -733,6 +741,7 @@ public class MessageAdapter extends ArrayAdapter { viewHolder.encryption = view.findViewById(R.id.message_encryption); viewHolder.audioPlayer = view.findViewById(R.id.audio_player); viewHolder.commands_list = view.findViewById(R.id.commands_list); + viewHolder.thread_identicon = view.findViewById(R.id.thread_identicon); break; case STATUS: view = activity.getLayoutInflater().inflate(R.layout.message_status, parent, false); @@ -751,6 +760,19 @@ public class MessageAdapter extends ArrayAdapter { } } + if (viewHolder.thread_identicon != null) { + viewHolder.thread_identicon.setVisibility(View.GONE); + final Element thread = message.getThread(); + if (thread != null) { + final String threadId = thread.getContent(); + if (threadId != null) { + viewHolder.thread_identicon.setVisibility(View.VISIBLE); + viewHolder.thread_identicon.setColor(UIHelper.getColorForName(threadId)); + viewHolder.thread_identicon.setHash(UIHelper.identiconHash(threadId)); + } + } + } + boolean darkBackground = type == RECEIVED && (!isInValidSession || mUseGreenBackground) || activity.isDarkTheme(); if (type == DATE_SEPARATOR) { @@ -822,6 +844,18 @@ public class MessageAdapter extends ArrayAdapter { resetClickListener(viewHolder.message_box, viewHolder.messageBody); + viewHolder.message_box.setOnClickListener(v -> { + if (MessageAdapter.this.mOnMessageBoxClickedListener != null) { + MessageAdapter.this.mOnMessageBoxClickedListener + .onContactPictureClicked(message); + } + }); + viewHolder.messageBody.setOnClickListener(v -> { + if (MessageAdapter.this.mOnMessageBoxClickedListener != null) { + MessageAdapter.this.mOnMessageBoxClickedListener + .onContactPictureClicked(message); + } + }); viewHolder.contact_picture.setOnClickListener(v -> { if (MessageAdapter.this.mOnContactPictureClickedListener != null) { MessageAdapter.this.mOnContactPictureClickedListener @@ -1028,6 +1062,7 @@ public class MessageAdapter extends ArrayAdapter { protected TextView status_message; protected TextView encryption; protected ListView commands_list; + protected GithubIdenticonView thread_identicon; } class ThumbnailTask extends AsyncTask { diff --git a/src/main/java/eu/siacs/conversations/utils/UIHelper.java b/src/main/java/eu/siacs/conversations/utils/UIHelper.java index 7692138be38c01d2268ee43e7626cdae00719e86..2bbcd08b7c5094da14ad123e9d0ce05bd850c593 100644 --- a/src/main/java/eu/siacs/conversations/utils/UIHelper.java +++ b/src/main/java/eu/siacs/conversations/utils/UIHelper.java @@ -11,8 +11,10 @@ import androidx.annotation.ColorInt; import androidx.core.content.res.ResourcesCompat; import com.google.common.base.Strings; +import com.google.common.primitives.Ints; import java.math.BigInteger; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.Arrays; import java.util.Calendar; @@ -229,6 +231,16 @@ public class UIHelper { } } + public static int identiconHash(String name) { + try { + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + byte[] digest = sha1.digest(name.getBytes(StandardCharsets.UTF_8)); + return Ints.fromByteArray(digest); + } catch (Exception e) { + return 0; + } + } + public static int getColorForName(String name) { return getColorForName(name, false); } diff --git a/src/main/res/layout/account_row.xml b/src/main/res/layout/account_row.xml index 914ee1950002312e4dcfbeda64954244f17737e1..3457b68bb5311044d55c9ea709666046584f4f2f 100644 --- a/src/main/res/layout/account_row.xml +++ b/src/main/res/layout/account_row.xml @@ -22,6 +22,7 @@ android:layout_width="fill_parent" android:layout_height="wrap_content" android:layout_centerVertical="true" + android:layout_toEndOf="@+id/account_image" android:layout_toRightOf="@+id/account_image" android:orientation="vertical" android:paddingLeft="@dimen/avatar_item_distance" diff --git a/src/main/res/layout/fragment_conversation.xml b/src/main/res/layout/fragment_conversation.xml index 05915e0c7e88662368fdfdf60d2f635d6acae685..477ecf3f012a51b675de67ac4593e09424bc243b 100644 --- a/src/main/res/layout/fragment_conversation.xml +++ b/src/main/res/layout/fragment_conversation.xml @@ -60,10 +60,10 @@ + + + + + + + + + - - - - - - - + android:layout_alignParentBottom="true"> + + + + + + + + + + + + +