diff --git a/CHANGELOG.md b/CHANGELOG.md index 05381ea94..3ed69596c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * Autoplay all voice messages in a chat * Allow to share location for 24 hours * Allow mini-apps to play audio without user interaction +* Allow to paste and open invitation links from search * Mark chats as unread (long tap a chat and select the corresponding option from the three-dot-menu) * Add "Mark all as read" option to profile menu in the profile switcher * Fix process of upgrading from a very old version of the app diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationListActivity.java b/src/main/java/org/thoughtcrime/securesms/ConversationListActivity.java index e63ad57de..eb41caa8c 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationListActivity.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationListActivity.java @@ -601,6 +601,13 @@ public class ConversationListActivity extends PassphraseRequiredActionBarActivit } } + public void handleQrFromSearch(String rawQrString) { + qrData = rawQrString; + new QrCodeHandler(this) + .handleQrData( + rawQrString, SecurejoinSource.Scan, SecurejoinUiPath.QrIcon, relayLockLauncher); + } + private void handleResetRelaying() { resetRelayingMessageContent(this); refreshTitle(); diff --git a/src/main/java/org/thoughtcrime/securesms/ConversationListItem.java b/src/main/java/org/thoughtcrime/securesms/ConversationListItem.java index 801162ab4..a8b97448c 100644 --- a/src/main/java/org/thoughtcrime/securesms/ConversationListItem.java +++ b/src/main/java/org/thoughtcrime/securesms/ConversationListItem.java @@ -44,9 +44,11 @@ import org.thoughtcrime.securesms.components.AvatarView; import org.thoughtcrime.securesms.components.DeliveryStatusView; import org.thoughtcrime.securesms.components.FromTextView; import org.thoughtcrime.securesms.connect.DcHelper; +import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.mms.GlideRequests; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.search.QrInviteData; import org.thoughtcrime.securesms.util.DateUtils; import org.thoughtcrime.securesms.util.ThemeUtil; import org.thoughtcrime.securesms.util.Util; @@ -216,6 +218,40 @@ public class ConversationListItem extends RelativeLayout avatar.setSeenRecently(false); } + public void bind(@NonNull QrInviteData inviteData, @NonNull GlideRequests glideRequests) { + this.selectedThreads = Collections.emptySet(); + + fromView.setText(inviteData.getDisplayTitle()); + fromView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + subjectView.setVisibility(VISIBLE); + subjectView.setText(inviteData.getDisplaySubtitle()); + subjectView.setTypeface(LIGHT_TYPEFACE); + subjectView.setTextColor( + ThemeUtil.getThemedColor(getContext(), R.attr.conversation_list_item_subject_color)); + + dateView.setText(""); + dateView.setCompoundDrawablesWithIntrinsicBounds(0, 0, 0, 0); + archivedBadgeView.setVisibility(GONE); + requestBadgeView.setVisibility(GONE); + unreadIndicator.setVisibility(GONE); + deliveryStatusIndicator.setNone(); + + setBatchState(false); + + if (inviteData.getContactId() > 0) { + DcContext dcContext = DcHelper.getContext(getContext()); + DcContact contact = dcContext.getContact(inviteData.getContactId()); + Recipient recipient = new Recipient(getContext(), contact); + avatar.setAvatar(glideRequests, recipient, false); + avatar.setSeenRecently(contact.wasSeenRecently()); + } else { + avatar.setImageDrawable( + new GeneratedContactPhoto("+") + .asDrawable(getContext(), ThemeUtil.getDummyContactColor(getContext()))); + avatar.setSeenRecently(false); + } + } + @Override public void unbind() {} diff --git a/src/main/java/org/thoughtcrime/securesms/search/QrInviteData.java b/src/main/java/org/thoughtcrime/securesms/search/QrInviteData.java new file mode 100644 index 000000000..2c3a56fa5 --- /dev/null +++ b/src/main/java/org/thoughtcrime/securesms/search/QrInviteData.java @@ -0,0 +1,91 @@ +package org.thoughtcrime.securesms.search; + +import android.content.Context; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.b44t.messenger.DcContact; +import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcLot; +import org.thoughtcrime.securesms.R; + +public class QrInviteData { + + private final String displayTitle; + private final String displaySubtitle; + private final String rawQrString; + private final int contactId; + + private QrInviteData( + @NonNull String displayTitle, + @NonNull String displaySubtitle, + @NonNull String rawQrString, + int contactId) { + this.displayTitle = displayTitle; + this.displaySubtitle = displaySubtitle; + this.rawQrString = rawQrString; + this.contactId = contactId; + } + + @Nullable + public static QrInviteData from( + @NonNull Context context, + @NonNull DcContext dcContext, + @NonNull DcLot qrParsed, + @NonNull String rawQrString) { + int state = qrParsed.getState(); + String title; + String subtitle; + int contactId = 0; + + switch (state) { + case DcContext.DC_QR_ASK_VERIFYCONTACT: + case DcContext.DC_QR_FPR_OK: + case DcContext.DC_QR_ADDR: + contactId = qrParsed.getId(); + DcContact contact = dcContext.getContact(contactId); + title = contact.getDisplayName(); + subtitle = context.getString(R.string.start_chat); + break; + case DcContext.DC_QR_ASK_VERIFYGROUP: + title = qrParsed.getText1(); + subtitle = context.getString(R.string.join_group); + break; + case DcContext.DC_QR_ASK_JOIN_BROADCAST: + title = qrParsed.getText1(); + subtitle = context.getString(R.string.join_channel); + break; + case DcContext.DC_QR_ACCOUNT: + case DcContext.DC_QR_LOGIN: + title = qrParsed.getText1(); + subtitle = context.getString(R.string.add_transport); + break; + case DcContext.DC_QR_PROXY: + title = qrParsed.getText1(); + subtitle = context.getString(R.string.proxy_use_proxy); + break; + default: + return null; + } + + return new QrInviteData(title, subtitle, rawQrString, contactId); + } + + @NonNull + public String getDisplayTitle() { + return displayTitle; + } + + @NonNull + public String getDisplaySubtitle() { + return displaySubtitle; + } + + @NonNull + public String getRawQrString() { + return rawQrString; + } + + public int getContactId() { + return contactId; + } +} diff --git a/src/main/java/org/thoughtcrime/securesms/search/SearchFragment.java b/src/main/java/org/thoughtcrime/securesms/search/SearchFragment.java index 0854e3110..55cf6e83b 100644 --- a/src/main/java/org/thoughtcrime/securesms/search/SearchFragment.java +++ b/src/main/java/org/thoughtcrime/securesms/search/SearchFragment.java @@ -200,6 +200,14 @@ public class SearchFragment extends BaseConversationListFragment } } + @Override + public void onInvitationClicked(@NonNull String rawQrString) { + ConversationListActivity conversationList = (ConversationListActivity) getActivity(); + if (conversationList != null) { + conversationList.handleQrFromSearch(rawQrString); + } + } + public void updateSearchQuery(@NonNull String query) { if (viewModel != null) { viewModel.updateQuery(query); diff --git a/src/main/java/org/thoughtcrime/securesms/search/SearchListAdapter.java b/src/main/java/org/thoughtcrime/securesms/search/SearchListAdapter.java index 24dbd0faf..cb87c4b93 100644 --- a/src/main/java/org/thoughtcrime/securesms/search/SearchListAdapter.java +++ b/src/main/java/org/thoughtcrime/securesms/search/SearchListAdapter.java @@ -25,6 +25,7 @@ import org.thoughtcrime.securesms.util.StickyHeaderDecoration; class SearchListAdapter extends BaseConversationListAdapter implements StickyHeaderDecoration.StickyHeaderAdapter { + private static final int TYPE_QR_INVITE = 0; private static final int TYPE_CHATS = 1; private static final int TYPE_CONTACTS = 2; private static final int TYPE_MESSAGES = 3; @@ -57,6 +58,14 @@ class SearchListAdapter @Override public void onBindViewHolder(@NonNull SearchResultViewHolder holder, int position) { + if (isQrInvitePosition(position)) { + QrInviteData inviteData = searchResult.getQrInviteData(); + if (inviteData != null) { + holder.bind(inviteData, glideRequests, eventListener); + } + return; + } + DcChatlist.Item conversationResult = getConversationResult(position); if (conversationResult != null) { @@ -97,7 +106,9 @@ class SearchListAdapter @Override public long getHeaderId(int position) { - if (getConversationResult(position) != null) { + if (isQrInvitePosition(position)) { + return TYPE_QR_INVITE; + } else if (getConversationResult(position) != null) { return TYPE_CHATS; } else if (getContactResult(position) != null) { return TYPE_CONTACTS; @@ -116,33 +127,39 @@ class SearchListAdapter @Override public void onBindHeaderViewHolder(HeaderViewHolder viewHolder, int position) { int headerType = (int) getHeaderId(position); - int textId = R.plurals.n_messages; - int count = 1; - boolean maybeLimitedTo1000 = false; + String title; - switch (headerType) { - case TYPE_CHATS: - textId = R.plurals.n_chats; - count = searchResult.getChats().getCnt(); - break; - case TYPE_CONTACTS: - textId = R.plurals.n_contacts; - count = searchResult.getContacts().length; - break; - case TYPE_MESSAGES: - textId = R.plurals.n_messages; - count = searchResult.getMessages().length; - maybeLimitedTo1000 = - count == 1000; // a count of 1000 results may be limited, see documentation of - // dc_search_msgs() - break; - } + if (headerType == TYPE_QR_INVITE) { + title = context.getString(R.string.link); + } else { + int textId = R.plurals.n_messages; + int count = 1; + boolean maybeLimitedTo1000 = false; - String title = context.getResources().getQuantityString(textId, count, count); - if (maybeLimitedTo1000) { - title = - title.replace( - "000", "000+"); // skipping the first digit allows formattings as "1.000" or "1,000" + switch (headerType) { + case TYPE_CHATS: + textId = R.plurals.n_chats; + count = searchResult.getChats().getCnt(); + break; + case TYPE_CONTACTS: + textId = R.plurals.n_contacts; + count = searchResult.getContacts().length; + break; + case TYPE_MESSAGES: + textId = R.plurals.n_messages; + count = searchResult.getMessages().length; + maybeLimitedTo1000 = + count == 1000; // a count of 1000 results may be limited, see documentation of + // dc_search_msgs() + break; + } + + title = context.getResources().getQuantityString(textId, count, count); + if (maybeLimitedTo1000) { + title = + title.replace( + "000", "000+"); // skipping the first digit allows formattings as "1.000" or "1,000" + } } viewHolder.bind(title); } @@ -160,10 +177,19 @@ class SearchListAdapter notifyDataSetChanged(); } + private int getQrInviteCount() { + return searchResult.getQrInviteData() != null ? 1 : 0; + } + + private boolean isQrInvitePosition(int position) { + return position == 0 && searchResult.getQrInviteData() != null; + } + @Nullable private DcChatlist.Item getConversationResult(int position) { - if (position < searchResult.getChats().getCnt()) { - return searchResult.getChats().getItem(position); + int offset = position - getQrInviteCount(); + if (offset >= 0 && offset < searchResult.getChats().getCnt()) { + return searchResult.getChats().getItem(offset); } return null; } @@ -185,7 +211,7 @@ class SearchListAdapter } private int getFirstContactIndex() { - return searchResult.getChats().getCnt(); + return getQrInviteCount() + searchResult.getChats().getCnt(); } private int getFirstMessageIndex() { @@ -200,6 +226,8 @@ class SearchListAdapter void onContactClicked(@NonNull DcContact contact); void onMessageClicked(@NonNull DcMsg message); + + void onInvitationClicked(@NonNull String rawQrString); } static class SearchResultViewHolder extends RecyclerView.ViewHolder { @@ -257,9 +285,20 @@ class SearchListAdapter root.setOnClickListener(view -> eventListener.onMessageClicked(messageResult)); } + void bind( + @NonNull QrInviteData inviteData, + @NonNull GlideRequests glideRequests, + @NonNull EventListener eventListener) { + root.bind(inviteData, glideRequests); + root.setOnClickListener( + view -> eventListener.onInvitationClicked(inviteData.getRawQrString())); + root.setOnLongClickListener(null); + } + void recycle() { root.unbind(); root.setOnClickListener(null); + root.setOnLongClickListener(null); } } diff --git a/src/main/java/org/thoughtcrime/securesms/search/SearchViewModel.java b/src/main/java/org/thoughtcrime/securesms/search/SearchViewModel.java index f391e5b3a..ba2570602 100644 --- a/src/main/java/org/thoughtcrime/securesms/search/SearchViewModel.java +++ b/src/main/java/org/thoughtcrime/securesms/search/SearchViewModel.java @@ -10,6 +10,7 @@ import androidx.lifecycle.ViewModel; import androidx.lifecycle.ViewModelProvider; import com.b44t.messenger.DcChatlist; import com.b44t.messenger.DcContext; +import com.b44t.messenger.DcLot; import org.thoughtcrime.securesms.connect.DcHelper; import org.thoughtcrime.securesms.search.model.SearchResult; import org.thoughtcrime.securesms.util.Util; @@ -18,13 +19,15 @@ class SearchViewModel extends ViewModel { private static final String TAG = "SearchViewModel"; private final ObservingLiveData searchResult; private String lastQuery; + private final Context appContext; private final DcContext dcContext; private boolean forwarding = false; private boolean inBgSearch; private boolean needsAnotherBgSearch; SearchViewModel(@NonNull Context context) { - this.dcContext = DcHelper.getContext(context.getApplicationContext()); + this.appContext = context.getApplicationContext(); + this.dcContext = DcHelper.getContext(appContext); this.searchResult = new ObservingLiveData(); } @@ -73,6 +76,12 @@ class SearchViewModel extends ViewModel { return; } + QrInviteData qrInviteData = null; + if (!forwarding && query.contains(":")) { + DcLot qrParsed = dcContext.checkQr(query); + qrInviteData = QrInviteData.from(appContext, dcContext, qrParsed, query); + } + // #1 search for chats long startMs = System.currentTimeMillis(); DcChatlist conversations = @@ -83,7 +92,8 @@ class SearchViewModel extends ViewModel { // #2 search for contacts if (!query.equals(lastQuery) && overallCnt > 0) { Log.i(TAG, "... skipping getContacts() and searchMsgs(), more recent search pending"); - callback.onResult(new SearchResult(query, new int[0], conversations, new int[0])); + callback.onResult( + new SearchResult(query, new int[0], conversations, new int[0], qrInviteData)); return; } @@ -95,19 +105,19 @@ class SearchViewModel extends ViewModel { // #3 search for messages if (forwarding) { Log.i(TAG, "... searchMsgs() disabled by caller"); - callback.onResult(new SearchResult(query, contacts, conversations, new int[0])); + callback.onResult(new SearchResult(query, contacts, conversations, new int[0], qrInviteData)); return; } if (query.length() <= 1) { Log.i(TAG, "... skipping searchMsgs(), string too short"); - callback.onResult(new SearchResult(query, contacts, conversations, new int[0])); + callback.onResult(new SearchResult(query, contacts, conversations, new int[0], qrInviteData)); return; } if (!query.equals(lastQuery) && overallCnt > 0) { Log.i(TAG, "... skipping searchMsgs(), more recent search pending"); - callback.onResult(new SearchResult(query, contacts, conversations, new int[0])); + callback.onResult(new SearchResult(query, contacts, conversations, new int[0], qrInviteData)); return; } @@ -115,7 +125,7 @@ class SearchViewModel extends ViewModel { int[] messages = dcContext.searchMsgs(0, query); Log.i(TAG, "⏰ searchMsgs(" + query + "): " + (System.currentTimeMillis() - startMs) + "ms"); - callback.onResult(new SearchResult(query, contacts, conversations, messages)); + callback.onResult(new SearchResult(query, contacts, conversations, messages, qrInviteData)); } @NonNull diff --git a/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java b/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java index edd21447d..c0570be73 100644 --- a/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java +++ b/src/main/java/org/thoughtcrime/securesms/search/model/SearchResult.java @@ -1,7 +1,9 @@ package org.thoughtcrime.securesms.search.model; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.b44t.messenger.DcChatlist; +import org.thoughtcrime.securesms.search.QrInviteData; /** * Represents an all-encompassing search result that can contain various result for different @@ -16,16 +18,27 @@ public class SearchResult { private final int[] contacts; private final DcChatlist conversations; private final int[] messages; + private final QrInviteData qrInviteData; public SearchResult( @NonNull String query, @NonNull int[] contacts, @NonNull DcChatlist conversations, @NonNull int[] messages) { + this(query, contacts, conversations, messages, null); + } + + public SearchResult( + @NonNull String query, + @NonNull int[] contacts, + @NonNull DcChatlist conversations, + @NonNull int[] messages, + @Nullable QrInviteData qrInviteData) { this.query = query; this.contacts = contacts; this.conversations = conversations; this.messages = messages; + this.qrInviteData = qrInviteData; } public int[] getContacts() { @@ -44,8 +57,16 @@ public class SearchResult { return query; } + @Nullable + public QrInviteData getQrInviteData() { + return qrInviteData; + } + public int size() { - return contacts.length + conversations.getCnt() + messages.length; + return (qrInviteData != null ? 1 : 0) + + contacts.length + + conversations.getCnt() + + messages.length; } public boolean isEmpty() {