diff --git a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java index 18553dae1e..95e60257d6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/NewConversationActivity.java @@ -100,7 +100,7 @@ public class NewConversationActivity extends ContactSelectionActivity private void launch(Recipient recipient) { Intent intent = new Intent(this, ConversationActivity.class); - intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId()); + intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId().serialize()); intent.putExtra(ConversationActivity.TEXT_EXTRA, getIntent().getStringExtra(ConversationActivity.TEXT_EXTRA)); intent.setDataAndType(getIntent().getData(), getIntent().getType()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java b/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java index 58d55f7f01..036f16292f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/SmsSendtoActivity.java @@ -53,7 +53,7 @@ public class SmsSendtoActivity extends Activity { nextIntent = new Intent(this, ConversationActivity.class); nextIntent.putExtra(ConversationActivity.TEXT_EXTRA, destination.getBody()); nextIntent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId); - nextIntent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId()); + nextIntent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId().serialize()); } return nextIntent; } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java index d205bc1573..48e46c2b71 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationActivity.java @@ -24,6 +24,7 @@ import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; +import android.content.pm.ShortcutManager; import android.content.res.Configuration; import android.graphics.Bitmap; import android.graphics.Color; @@ -242,6 +243,7 @@ import org.thoughtcrime.securesms.util.BitmapUtil; import org.thoughtcrime.securesms.util.CharacterCalculator.CharacterState; import org.thoughtcrime.securesms.util.CommunicationActions; import org.thoughtcrime.securesms.util.ContextUtil; +import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.DrawableUtil; import org.thoughtcrime.securesms.util.DynamicDarkToolbarTheme; import org.thoughtcrime.securesms.util.DynamicLanguage; @@ -413,12 +415,17 @@ public class ConversationActivity extends PassphraseRequiredActivity long threadId) { Intent intent = new Intent(context, ConversationActivity.class); - intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId); + intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId.serialize()); intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId); + intent.setAction(Intent.ACTION_DEFAULT); return intent; } + public static @NonNull RecipientId getRecipientId(@NonNull Intent intent) { + return RecipientId.from(Objects.requireNonNull(intent.getStringExtra(RECIPIENT_EXTRA))); + } + @Override protected void onPreCreate() { dynamicTheme.onCreate(this); @@ -427,7 +434,7 @@ public class ConversationActivity extends PassphraseRequiredActivity @Override protected void onCreate(Bundle state, boolean ready) { - RecipientId recipientId = getIntent().getParcelableExtra(RECIPIENT_EXTRA); + RecipientId recipientId = getRecipientId(getIntent()); if (recipientId == null) { Log.w(TAG, "[onCreate] Missing recipientId!"); @@ -437,7 +444,7 @@ public class ConversationActivity extends PassphraseRequiredActivity return; } - + reportShortcutLaunch(recipientId); setContentView(R.layout.conversation_activity); getWindow().getDecorView().setBackgroundResource(R.color.signal_background_primary); @@ -505,7 +512,7 @@ public class ConversationActivity extends PassphraseRequiredActivity silentlySetComposeText(""); } - RecipientId recipientId = intent.getParcelableExtra(RECIPIENT_EXTRA); + RecipientId recipientId = getRecipientId(intent); if (recipientId == null) { Log.w(TAG, "[onNewIntent] Missing recipientId!"); @@ -515,6 +522,7 @@ public class ConversationActivity extends PassphraseRequiredActivity return; } + reportShortcutLaunch(recipientId); setIntent(intent); initializeResources(); initializeSecurity(recipient.get().isRegistered(), isDefaultSms).addListener(new AssertedSuccessListener() { @@ -559,6 +567,8 @@ public class ConversationActivity extends PassphraseRequiredActivity } ApplicationDependencies.getMessageNotifier().setVisibleThread(threadId); + + ConversationUtil.pushShortcutForRecipient(getApplicationContext(), recipientSnapshot); } @Override @@ -743,6 +753,17 @@ public class ConversationActivity extends PassphraseRequiredActivity reactWithAnyEmojiStartPage = savedInstanceState.getInt(STATE_REACT_WITH_ANY_PAGE, 0); } + private void reportShortcutLaunch(@NonNull RecipientId recipientId) { + if (Build.VERSION.SDK_INT < ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + return; + } + + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(this); + if (shortcutManager != null) { + shortcutManager.reportShortcutUsed(ConversationUtil.getShortcutId(recipientId)); + } + } + private void handleImageFromDeviceCameraApp() { if (attachmentManager.getCaptureUri() == null) { Log.w(TAG, "No image available."); @@ -1955,7 +1976,7 @@ public class ConversationActivity extends PassphraseRequiredActivity recipient.removeObservers(this); } - recipient = Recipient.live(getIntent().getParcelableExtra(RECIPIENT_EXTRA)); + recipient = Recipient.live(getRecipientId(getIntent())); threadId = getIntent().getLongExtra(THREAD_ID_EXTRA, -1); distributionType = getIntent().getIntExtra(DISTRIBUTION_TYPE_EXTRA, ThreadDatabase.DistributionTypes.DEFAULT); glideRequests = GlideApp.with(this); @@ -1963,7 +1984,6 @@ public class ConversationActivity extends PassphraseRequiredActivity recipient.observe(this, this::onRecipientChanged); } - private void initializeLinkPreviewObserver() { linkPreviewViewModel = ViewModelProviders.of(this, new LinkPreviewViewModel.Factory(new LinkPreviewRepository())).get(LinkPreviewViewModel.class); diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java index 2337dcce55..9350f0b850 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationFragment.java @@ -480,7 +480,7 @@ public class ConversationFragment extends LoggingFragment { int startingPosition = getStartPosition(); - this.recipient = Recipient.live(getActivity().getIntent().getParcelableExtra(ConversationActivity.RECIPIENT_EXTRA)); + this.recipient = Recipient.live(ConversationActivity.getRecipientId(requireActivity().getIntent())); this.threadId = this.getActivity().getIntent().getLongExtra(ConversationActivity.THREAD_ID_EXTRA, -1); this.markReadHelper = new MarkReadHelper(threadId, requireContext()); @@ -1498,8 +1498,8 @@ public class ConversationFragment extends LoggingFragment { public boolean onMenuItemClick(MenuItem item) { switch (item.getItemId()) { case R.id.action_info: handleDisplayDetails(conversationMessage); return true; - case R.id.action_delete: handleDeleteMessages(SetUtil.newHashSet(conversationMessage)); return true; - case R.id.action_copy: handleCopyMessage(SetUtil.newHashSet(conversationMessage)); return true; + case R.id.action_delete: handleDeleteMessages(SetUtil.newHashSet(conversationMessage)); return true; + case R.id.action_copy: handleCopyMessage(SetUtil.newHashSet(conversationMessage)); return true; case R.id.action_reply: handleReplyMessage(conversationMessage); return true; case R.id.action_multiselect: handleEnterMultiSelect(conversationMessage); return true; case R.id.action_forward: handleForwardMessage(conversationMessage); return true; diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java index a56526216e..d4136de99c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/ConversationPopupActivity.java @@ -84,7 +84,7 @@ public class ConversationPopupActivity extends ConversationActivity { public void onSuccess(Long result) { ActivityOptionsCompat transition = ActivityOptionsCompat.makeScaleUpAnimation(getWindow().getDecorView(), 0, 0, getWindow().getAttributes().width, getWindow().getAttributes().height); Intent intent = new Intent(ConversationPopupActivity.this, ConversationActivity.class); - intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, getRecipient().getId()); + intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, getRecipient().getId().serialize()); intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, result); startActivity(intent, transition.toBundle()); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java index 7c6e41b0cb..5457a6f5c6 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ThreadDatabase.java @@ -53,6 +53,7 @@ import org.thoughtcrime.securesms.recipients.RecipientId; import org.thoughtcrime.securesms.recipients.RecipientUtil; import org.thoughtcrime.securesms.storage.StorageSyncHelper; import org.thoughtcrime.securesms.tracing.Trace; +import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.CursorUtil; import org.thoughtcrime.securesms.util.JsonUtils; import org.thoughtcrime.securesms.util.SqlUtil; @@ -245,6 +246,7 @@ public class ThreadDatabase extends Database { SQLiteDatabase db = databaseHelper.getWritableDatabase(); db.delete(TABLE_NAME, ID_WHERE, new String[] {threadId + ""}); notifyConversationListListeners(); + ConversationUtil.clearShortcuts(context, Collections.singleton(threadId)); } private void deleteThreads(Set threadIds) { @@ -259,12 +261,14 @@ public class ThreadDatabase extends Database { db.delete(TABLE_NAME, where, null); notifyConversationListListeners(); + ConversationUtil.clearShortcuts(context, threadIds); } private void deleteAllThreads() { SQLiteDatabase db = databaseHelper.getWritableDatabase(); db.delete(TABLE_NAME, null, null); notifyConversationListListeners(); + ConversationUtil.clearAllShortcuts(context); } public void trimAllThreads(int length, long trimBeforeDate) { @@ -1194,6 +1198,10 @@ public class ThreadDatabase extends Database { } } + public @NonNull ThreadRecord getThreadRecordFor(@NonNull Recipient recipient) { + return Objects.requireNonNull(getThreadRecord(getThreadIdFor(recipient))); + } + @NonNull MergeResult merge(@NonNull RecipientId primaryRecipientId, @NonNull RecipientId secondaryRecipientId) { if (!databaseHelper.getWritableDatabase().inTransaction()) { throw new IllegalStateException("Must be in a transaction!"); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java index 9e40c3ed2d..70b92c1616 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/DefaultMessageNotifier.java @@ -135,7 +135,7 @@ public class DefaultMessageNotifier implements MessageNotifier { sendInThreadNotification(context, recipient); } else { Intent intent = new Intent(context, ConversationActivity.class); - intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId()); + intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId().serialize()); intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId); intent.setData((Uri.parse("custom://" + System.currentTimeMillis()))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java index 5b3bbac027..b06f8023d4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/NotificationState.java @@ -182,7 +182,7 @@ public class NotificationState { if (threads.size() != 1) throw new AssertionError("We only support replies to single thread notifications! " + threads.size()); Intent intent = new Intent(context, ConversationPopupActivity.class); - intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId()); + intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipient.getId().serialize()); intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, (long)threads.toArray()[0]); intent.setData((Uri.parse("custom://"+System.currentTimeMillis()))); diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java index 82a27347f8..f471a2f147 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/SingleRecipientNotificationBuilder.java @@ -36,6 +36,7 @@ import org.thoughtcrime.securesms.preferences.widgets.NotificationPrivacyPrefere import org.thoughtcrime.securesms.recipients.Recipient; import org.thoughtcrime.securesms.util.AvatarUtil; import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.TextSecurePreferences; import org.thoughtcrime.securesms.util.Util; import org.whispersystems.libsignal.util.guava.Optional; @@ -88,6 +89,8 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil setContentTitle(context.getString(R.string.SingleRecipientNotificationBuilder_signal)); setLargeIcon(new GeneratedContactPhoto("Unknown", R.drawable.ic_profile_outline_40).asDrawable(context, ContactColors.UNKNOWN_COLOR.toConversationColor(context))); } + + setShortcutId(ConversationUtil.getShortcutId(recipient)); } private Drawable getContactDrawable(@NonNull Recipient recipient) { @@ -230,13 +233,14 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil { SpannableStringBuilder stringBuilder = new SpannableStringBuilder(); Person.Builder personBuilder = new Person.Builder() - .setKey(individualRecipient.getId().serialize()) + .setKey(ConversationUtil.getShortcutId(individualRecipient)) .setBot(false); this.threadRecipient = threadRecipient; if (privacy.isDisplayContact()) { personBuilder.setName(individualRecipient.getDisplayName(context)); + personBuilder.setUri(individualRecipient.isSystemContact() ? individualRecipient.getContactUri().toString() : null); Bitmap bitmap = getLargeBitmap(getContactDrawable(individualRecipient)); if (bitmap != null) { @@ -283,13 +287,8 @@ public class SingleRecipientNotificationBuilder extends AbstractNotificationBuil } private void applyMessageStyle() { - NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle( - new Person.Builder() - .setBot(false) - .setName(Recipient.self().getDisplayName(context)) - .setKey(Recipient.self().getId().serialize()) - .setIcon(AvatarUtil.getIconForNotification(context, Recipient.self())) - .build()); + ConversationUtil.pushShortcutForRecipientIfNeededSync(context, threadRecipient); + NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle(ConversationUtil.buildPersonCompat(context, Recipient.self())); if (threadRecipient.isGroup()) { if (privacy.isDisplayContact()) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java b/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java index 3966993418..7f10859132 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/InternalOptionsPreferenceFragment.java @@ -19,6 +19,7 @@ import org.thoughtcrime.securesms.jobs.StorageForcePushJob; import org.thoughtcrime.securesms.keyvalue.InternalValues; import org.thoughtcrime.securesms.keyvalue.SignalStore; import org.thoughtcrime.securesms.logging.Log; +import org.thoughtcrime.securesms.util.ConversationUtil; public class InternalOptionsPreferenceFragment extends CorrectedPreferenceFragment { private static final String TAG = Log.tag(InternalOptionsPreferenceFragment.class); @@ -69,6 +70,12 @@ public class InternalOptionsPreferenceFragment extends CorrectedPreferenceFragme Toast.makeText(getContext(), "Scheduled storage force push", Toast.LENGTH_SHORT).show(); return true; }); + + findPreference("pref_delete_dynamic_shortcuts").setOnPreferenceClickListener(preference -> { + ConversationUtil.clearAllShortcuts(requireContext()); + Toast.makeText(getContext(), "Deleted all dynamic shortcuts.", Toast.LENGTH_SHORT).show(); + return true; + }); } private void initializeSwitchPreference(@NonNull PreferenceDataStore preferenceDataStore, diff --git a/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java b/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java index 96665623be..a690360ef5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java +++ b/app/src/main/java/org/thoughtcrime/securesms/service/DirectShareService.java @@ -4,51 +4,78 @@ package org.thoughtcrime.securesms.service; import android.content.ComponentName; import android.content.Context; import android.content.IntentFilter; -import android.database.Cursor; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; import android.graphics.Bitmap; import android.graphics.drawable.Icon; import android.os.Build; import android.os.Bundle; import android.service.chooser.ChooserTarget; import android.service.chooser.ChooserTargetService; + import androidx.annotation.NonNull; import androidx.annotation.RequiresApi; import androidx.appcompat.view.ContextThemeWrapper; +import androidx.core.content.ContextCompat; + +import com.annimon.stream.Stream; import org.thoughtcrime.securesms.R; -import org.thoughtcrime.securesms.sharing.ShareActivity; import org.thoughtcrime.securesms.database.DatabaseFactory; import org.thoughtcrime.securesms.database.ThreadDatabase; import org.thoughtcrime.securesms.database.model.ThreadRecord; import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.sharing.ShareActivity; +import org.thoughtcrime.securesms.util.AvatarUtil; import org.thoughtcrime.securesms.util.BitmapUtil; +import org.thoughtcrime.securesms.util.ConversationUtil; import org.thoughtcrime.securesms.util.FeatureFlags; +import org.thoughtcrime.securesms.util.ServiceUtil; -import java.util.LinkedList; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.ExecutionException; @RequiresApi(api = Build.VERSION_CODES.M) public class DirectShareService extends ChooserTargetService { - private static final String TAG = DirectShareService.class.getSimpleName(); + + private static final String TAG = DirectShareService.class.getSimpleName(); + private static final int MAX_TARGETS = 10; @Override public List onGetChooserTargets(ComponentName targetActivityName, IntentFilter matchedFilter) { - List results = new LinkedList<>(); - ComponentName componentName = new ComponentName(this, ShareActivity.class); - ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(this); - Cursor cursor = threadDatabase.getRecentConversationList(10, false, FeatureFlags.groupsV1ForcedMigration()); + Map results = new LinkedHashMap<>(); - try { - ThreadDatabase.Reader reader = threadDatabase.readerFor(cursor); + if (Build.VERSION.SDK_INT >= ConversationUtil.CONVERSATION_SUPPORT_VERSION) { + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(this); + if (shortcutManager != null && !shortcutManager.getDynamicShortcuts().isEmpty()) { + addChooserTargetsFromDynamicShortcuts(results, shortcutManager.getDynamicShortcuts()); + } + + if (results.size() >= MAX_TARGETS) { + return new ArrayList<>(results.values()); + } + } + + ComponentName componentName = new ComponentName(this, ShareActivity.class); + ThreadDatabase threadDatabase = DatabaseFactory.getThreadDatabase(this); + + try (ThreadDatabase.Reader reader = threadDatabase.readerFor(threadDatabase.getRecentConversationList(MAX_TARGETS, false, FeatureFlags.groupsV1ForcedMigration()))) { ThreadRecord record; while ((record = reader.getNext()) != null) { + if (results.containsKey(record.getRecipient().getId())) { + continue; + } + Recipient recipient = Recipient.resolved(record.getRecipient().getId()); String name = recipient.getDisplayName(this); @@ -71,25 +98,53 @@ public class DirectShareService extends ChooserTargetService { avatar = getFallbackDrawable(recipient); } - Bundle bundle = new Bundle(); - bundle.putLong(ShareActivity.EXTRA_THREAD_ID, record.getThreadId()); - bundle.putString(ShareActivity.EXTRA_RECIPIENT_ID, recipient.getId().serialize()); - bundle.putInt(ShareActivity.EXTRA_DISTRIBUTION_TYPE, record.getDistributionType()); - bundle.setClassLoader(getClassLoader()); + Bundle bundle = buildExtras(record); - results.add(new ChooserTarget(name, Icon.createWithBitmap(avatar), 1.0f, componentName, bundle)); + results.put(recipient.getId(), new ChooserTarget(name, Icon.createWithBitmap(avatar), 1.0f, componentName, bundle)); } - return results; - } finally { - if (cursor != null) cursor.close(); + return new ArrayList<>(results.values()); } } + private @NonNull Bundle buildExtras(@NonNull ThreadRecord threadRecord) { + Bundle bundle = new Bundle(); + + bundle.putLong(ShareActivity.EXTRA_THREAD_ID, threadRecord.getThreadId()); + bundle.putString(ShareActivity.EXTRA_RECIPIENT_ID, threadRecord.getRecipient().getId().serialize()); + bundle.putInt(ShareActivity.EXTRA_DISTRIBUTION_TYPE, threadRecord.getDistributionType()); + + return bundle; + } + private Bitmap getFallbackDrawable(@NonNull Recipient recipient) { Context themedContext = new ContextThemeWrapper(this, R.style.TextSecure_LightTheme); return BitmapUtil.createFromDrawable(recipient.getFallbackContactPhotoDrawable(themedContext, false), getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_width), getResources().getDimensionPixelSize(android.R.dimen.notification_large_icon_height)); } + + @RequiresApi(ConversationUtil.CONVERSATION_SUPPORT_VERSION) + private void addChooserTargetsFromDynamicShortcuts(@NonNull Map targetMap, @NonNull List shortcutInfos) { + Stream.of(shortcutInfos) + .sorted((lhs, rhs) -> Integer.compare(lhs.getRank(), rhs.getRank())) + .takeWhileIndexed((idx, info) -> idx < MAX_TARGETS) + .forEach(info -> { + Recipient recipient = Recipient.resolved(RecipientId.from(info.getId())); + ChooserTarget target = buildChooserTargetFromShortcutInfo(info, recipient); + + targetMap.put(RecipientId.from(info.getId()), target); + }); + } + + @RequiresApi(ConversationUtil.CONVERSATION_SUPPORT_VERSION) + private @NonNull ChooserTarget buildChooserTargetFromShortcutInfo(@NonNull ShortcutInfo info, @NonNull Recipient recipient) { + ThreadRecord threadRecord = DatabaseFactory.getThreadDatabase(this).getThreadRecordFor(recipient); + + return new ChooserTarget(info.getShortLabel(), + AvatarUtil.getIconForShortcut(this, recipient), + info.getRank() / ((float) MAX_TARGETS), + new ComponentName(this, ShareActivity.class), + buildExtras(threadRecord)); + } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java index 3e20e94227..3eb07934af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/sharing/ShareActivity.java @@ -325,7 +325,7 @@ public class ShareActivity extends PassphraseRequiredActivity Log.i(TAG, "Shared data was not external."); } - intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId); + intent.putExtra(ConversationActivity.RECIPIENT_EXTRA, recipientId.serialize()); intent.putExtra(ConversationActivity.THREAD_ID_EXTRA, threadId); intent.putExtra(ConversationActivity.DISTRIBUTION_TYPE_EXTRA, ThreadDatabase.DistributionTypes.DEFAULT); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java index 442a7d0720..a0e4337a06 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/AvatarUtil.java @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.util; import android.content.Context; import android.graphics.Bitmap; import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; import android.text.TextUtils; import android.view.View; import android.widget.ImageView; @@ -10,6 +11,7 @@ import android.widget.ImageView; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.annotation.WorkerThread; import androidx.core.content.ContextCompat; import androidx.core.graphics.drawable.IconCompat; @@ -22,8 +24,11 @@ import com.bumptech.glide.request.transition.Transition; import org.thoughtcrime.securesms.R; import org.thoughtcrime.securesms.color.MaterialColor; import org.thoughtcrime.securesms.contacts.avatars.ContactColors; +import org.thoughtcrime.securesms.contacts.avatars.ContactPhoto; +import org.thoughtcrime.securesms.contacts.avatars.FallbackPhoto80dp; import org.thoughtcrime.securesms.contacts.avatars.GeneratedContactPhoto; import org.thoughtcrime.securesms.contacts.avatars.ProfileContactPhoto; +import org.thoughtcrime.securesms.logging.Log; import org.thoughtcrime.securesms.mms.GlideApp; import org.thoughtcrime.securesms.mms.GlideRequest; import org.thoughtcrime.securesms.recipients.Recipient; @@ -33,6 +38,8 @@ import java.util.concurrent.ExecutionException; public final class AvatarUtil { + private static final String TAG = Log.tag(AvatarUtil.class); + private AvatarUtil() { } @@ -93,6 +100,16 @@ public final class AvatarUtil { } } + @RequiresApi(ConversationUtil.CONVERSATION_SUPPORT_VERSION) + @WorkerThread + public static Icon getIconForShortcut(@NonNull Context context, @NonNull Recipient recipient) { + try { + return Icon.createWithAdaptiveBitmap(getShortcutInfoBitmap(context, recipient)); + } catch (ExecutionException | InterruptedException e) { + return Icon.createWithAdaptiveBitmap(getFallbackForShortcut(context, recipient)); + } + } + @WorkerThread public static Bitmap getBitmapForNotification(@NonNull Context context, @NonNull Recipient recipient) { try { @@ -102,13 +119,8 @@ public final class AvatarUtil { } } - public static GlideRequest getSelfAvatarOrFallbackIcon(@NonNull Context context, @DrawableRes int fallbackIcon) { - return GlideApp.with(context) - .asDrawable() - .load(new ProfileContactPhoto(Recipient.self(), Recipient.self().getProfileAvatar())) - .error(fallbackIcon) - .circleCrop() - .diskCacheStrategy(DiskCacheStrategy.ALL); + private static @NonNull Bitmap getShortcutInfoBitmap(@NonNull Context context, @NonNull Recipient recipient) throws ExecutionException, InterruptedException { + return DrawableUtil.wrapBitmapForShortcutInfo(request(GlideApp.with(context).asBitmap(), context, recipient, false).circleCrop().submit().get()); } private static GlideRequest requestCircle(@NonNull GlideRequest glideRequest, @NonNull Context context, @NonNull Recipient recipient) { @@ -120,11 +132,40 @@ public final class AvatarUtil { } private static GlideRequest request(@NonNull GlideRequest glideRequest, @NonNull Context context, @NonNull Recipient recipient) { - return glideRequest.load(new ProfileContactPhoto(recipient, recipient.getProfileAvatar())) + return request(glideRequest, context, recipient, true); + } + + private static GlideRequest request(@NonNull GlideRequest glideRequest, @NonNull Context context, @NonNull Recipient recipient, boolean loadSelf) { + final ContactPhoto photo; + if (Recipient.self().equals(recipient) && loadSelf) { + photo = new ProfileContactPhoto(recipient, recipient.getProfileAvatar()); + } else { + photo = recipient.getContactPhoto(); + } + + return glideRequest.load(photo) .error(getFallback(context, recipient)) .diskCacheStrategy(DiskCacheStrategy.ALL); } + private static @NonNull Bitmap getFallbackForShortcut(@NonNull Context context, @NonNull Recipient recipient) { + @DrawableRes final int photoSource; + if (recipient.isSelf()) { + photoSource = R.drawable.ic_note_80; + } else if (recipient.isGroup()) { + photoSource = R.drawable.ic_group_80; + } else { + photoSource = R.drawable.ic_profile_80; + } + + Bitmap toWrap = DrawableUtil.toBitmap(new FallbackPhoto80dp(photoSource, recipient.getColor()).asDrawable(context, -1), ViewUtil.dpToPx(80), ViewUtil.dpToPx(80)); + Bitmap wrapped = DrawableUtil.wrapBitmapForShortcutInfo(toWrap); + + toWrap.recycle(); + + return wrapped; + } + private static Drawable getFallback(@NonNull Context context, @NonNull Recipient recipient) { String name = Optional.fromNullable(recipient.getDisplayName(context)).or(""); MaterialColor fallbackColor = recipient.getColor(); diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java new file mode 100644 index 0000000000..64d5441d7b --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ConversationUtil.java @@ -0,0 +1,239 @@ +package org.thoughtcrime.securesms.util; + +import android.app.Person; +import android.content.ComponentName; +import android.content.Context; +import android.content.pm.ShortcutInfo; +import android.content.pm.ShortcutManager; +import android.os.Build; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.annotation.WorkerThread; + +import com.annimon.stream.Stream; + +import org.thoughtcrime.securesms.R; +import org.thoughtcrime.securesms.conversation.ConversationActivity; +import org.thoughtcrime.securesms.database.DatabaseFactory; +import org.thoughtcrime.securesms.database.GroupDatabase; +import org.thoughtcrime.securesms.groups.GroupId; +import org.thoughtcrime.securesms.recipients.Recipient; +import org.thoughtcrime.securesms.recipients.RecipientId; +import org.thoughtcrime.securesms.util.concurrent.SignalExecutors; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +/** + * ConversationUtil encapsulates support for Android 11+'s new Conversations system + */ +public final class ConversationUtil { + + public static final int CONVERSATION_SUPPORT_VERSION = 30; + + private ConversationUtil() {} + + /** + * Pushes a new dynamic shortcut for the given recipient and updates the ranks of all current + * shortcuts. + */ + public static void pushShortcutForRecipient(@NonNull Context context, @NonNull Recipient recipient) { + if (Build.VERSION.SDK_INT >= CONVERSATION_SUPPORT_VERSION) { + SignalExecutors.BOUNDED.execute(() -> { + pushShortcutAndUpdateRanks(context, recipient); + }); + } + } + + /** + * Synchronously pushes a new dynamic shortcut for the given recipient if one does not already exist. + * + * If added, this recipient is given a high ranking with the intention of not appearing immediately in results. + */ + @WorkerThread + public static void pushShortcutForRecipientIfNeededSync(@NonNull Context context, @NonNull Recipient recipient) { + if (Build.VERSION.SDK_INT >= CONVERSATION_SUPPORT_VERSION) { + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(context); + String shortcutId = getShortcutId(recipient); + List shortcuts = shortcutManager.getDynamicShortcuts(); + + boolean hasPushedRecipientShortcut = Stream.of(shortcuts) + .filter(info -> Objects.equals(shortcutId, info.getId())) + .findFirst() + .isPresent(); + + if (!hasPushedRecipientShortcut) { + pushShortcutForRecipientInternal(context, recipient, shortcuts.size()); + } + } + } + + /** + * Clears all currently set dynamic shortcuts + */ + public static void clearAllShortcuts(@NonNull Context context) { + if (Build.VERSION.SDK_INT >= CONVERSATION_SUPPORT_VERSION) { + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(context); + List shortcutInfos = shortcutManager.getDynamicShortcuts(); + + shortcutManager.removeLongLivedShortcuts(Stream.of(shortcutInfos).map(ShortcutInfo::getId).toList()); + } + } + + /** + * Clears the shortcuts tied to a given thread. + */ + public static void clearShortcuts(@NonNull Context context, @NonNull Set threadIds) { + if (Build.VERSION.SDK_INT >= CONVERSATION_SUPPORT_VERSION) { + SignalExecutors.BOUNDED.execute(() -> { + List recipientIds = DatabaseFactory.getThreadDatabase(context).getRecipientIdsForThreadIds(threadIds); + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(context); + + shortcutManager.removeLongLivedShortcuts(Stream.of(recipientIds).map(ConversationUtil::getShortcutId).toList()); + }); + } + } + + /** + * Returns an ID that is unique between all recipients. + * + * @param recipientId The recipient ID to get a shortcut ID for + * + * @return A unique identifier that is stable for a given recipient id + */ + public static @NonNull String getShortcutId(@NonNull RecipientId recipientId) { + return recipientId.serialize(); + } + + /** + * Returns an ID that is unique between all recipients. + * + * @param recipient The recipient to get a shortcut for. + * + * @return A unique identifier that is stable for a given recipient id + */ + public static @NonNull String getShortcutId(@NonNull Recipient recipient) { + return getShortcutId(recipient.getId()); + } + + /** + * Updates the rank of each existing shortcut by 1 and then publishes a new shortcut of rank 0 + * for the given recipient. + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + private static void pushShortcutAndUpdateRanks(@NonNull Context context, @NonNull Recipient recipient) { + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(context); + List currentShortcuts = shortcutManager.getDynamicShortcuts(); + + if (Util.isEmpty(currentShortcuts)) { + for (ShortcutInfo shortcutInfo : currentShortcuts) { + RecipientId recipientId = RecipientId.from(shortcutInfo.getId()); + Recipient resolved = Recipient.resolved(recipientId); + ShortcutInfo updated = buildShortcutInfo(context, resolved, shortcutInfo.getRank() + 1); + + shortcutManager.pushDynamicShortcut(updated); + } + } + + pushShortcutForRecipientInternal(context, recipient, 0); + } + + /** + * Pushes a dynamic shortcut for a given recipient to the shortcut manager + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + private static void pushShortcutForRecipientInternal(@NonNull Context context, @NonNull Recipient recipient, int rank) { + ShortcutInfo shortcutInfo = buildShortcutInfo(context, recipient, rank); + ShortcutManager shortcutManager = ServiceUtil.getShortcutManager(context); + + shortcutManager.pushDynamicShortcut(shortcutInfo); + } + + /** + * Builds the shortcut info object for a given Recipient. + * + * @param context The Context under which we are operating + * @param recipient The Recipient to generate a ShortcutInfo for + * @param rank The rank that should be assigned to this recipient + * @return The new ShortcutInfo + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + private static @NonNull ShortcutInfo buildShortcutInfo(@NonNull Context context, + @NonNull Recipient recipient, + int rank) + { + Recipient resolved = recipient.resolve(); + Person[] persons = buildPersons(context, resolved); + long threadId = DatabaseFactory.getThreadDatabase(context).getThreadIdFor(resolved); + String shortName = resolved.isSelf() ? context.getString(R.string.note_to_self) : resolved.getShortDisplayName(context); + String longName = resolved.isSelf() ? context.getString(R.string.note_to_self) : resolved.getDisplayName(context); + + return new ShortcutInfo.Builder(context, getShortcutId(resolved)) + .setLongLived(true) + .setIntent(ConversationActivity.buildIntent(context, resolved.getId(), threadId)) + .setShortLabel(shortName) + .setLongLabel(longName) + .setIcon(AvatarUtil.getIconForShortcut(context, resolved)) + .setPersons(persons) + .setCategories(Collections.singleton(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION)) + .setActivity(new ComponentName(context, "org.thoughtcrime.securesms.RoutingActivity")) + .setRank(rank) + .build(); + } + + /** + * @return an array of Person objects correlating to members of a conversation (other than self) + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + private static @NonNull Person[] buildPersons(@NonNull Context context, @NonNull Recipient recipient) { + if (recipient.isGroup()) { + return buildPersonsForGroup(context, recipient.getGroupId().get()); + } else { + return new Person[]{buildPerson(context, recipient)}; + } + } + + /** + * @return an array of Person objects correlating to members of a group (other than self) + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + private static @NonNull Person[] buildPersonsForGroup(@NonNull Context context, @NonNull GroupId groupId) { + List members = DatabaseFactory.getGroupDatabase(context).getGroupMembers(groupId, GroupDatabase.MemberSet.FULL_MEMBERS_EXCLUDING_SELF); + + return Stream.of(members).map(member -> buildPerson(context, member.resolve())).toArray(Person[]::new); + } + + /** + * @return A Person object representing the given Recipient + */ + @RequiresApi(CONVERSATION_SUPPORT_VERSION) + @WorkerThread + private static @NonNull Person buildPerson(@NonNull Context context, @NonNull Recipient recipient) { + return new Person.Builder() + .setKey(getShortcutId(recipient.getId())) + .setName(recipient.getDisplayName(context)) + .setUri(recipient.isSystemContact() ? recipient.getContactUri().toString() : null) + .build(); + } + + /** + * @return A Compat Library Person object representing the given Recipient + */ + @WorkerThread + public static @NonNull androidx.core.app.Person buildPersonCompat(@NonNull Context context, @NonNull Recipient recipient) { + return new androidx.core.app.Person.Builder() + .setKey(getShortcutId(recipient.getId())) + .setName(recipient.getDisplayName(context)) + .setIcon(AvatarUtil.getIconForNotification(context, recipient)) + .setUri(recipient.isSystemContact() ? recipient.getContactUri().toString() : null) + .build(); + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java index 78e653e98f..cc454a5676 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/DrawableUtil.java @@ -6,10 +6,15 @@ import android.graphics.drawable.Drawable; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; +import androidx.camera.core.impl.SingleImageProxyBundle; import androidx.core.graphics.drawable.DrawableCompat; public final class DrawableUtil { + private static final int SHORTCUT_INFO_BITMAP_SIZE = ViewUtil.dpToPx(108); + private static final int SHORTCUT_INFO_WRAPPED_SIZE = ViewUtil.dpToPx(72); + private static final int SHORTCUT_INFO_PADDING = (SHORTCUT_INFO_BITMAP_SIZE - SHORTCUT_INFO_WRAPPED_SIZE) / 2; + private DrawableUtil() {} public static @NonNull Bitmap toBitmap(@NonNull Drawable drawable, int width, int height) { @@ -22,6 +27,16 @@ public final class DrawableUtil { return bitmap; } + public static @NonNull Bitmap wrapBitmapForShortcutInfo(@NonNull Bitmap toWrap) { + Bitmap bitmap = Bitmap.createBitmap(SHORTCUT_INFO_BITMAP_SIZE, SHORTCUT_INFO_BITMAP_SIZE, Bitmap.Config.ARGB_8888); + Bitmap scaled = Bitmap.createScaledBitmap(toWrap, SHORTCUT_INFO_WRAPPED_SIZE, SHORTCUT_INFO_WRAPPED_SIZE, true); + + Canvas canvas = new Canvas(bitmap); + canvas.drawBitmap(scaled, SHORTCUT_INFO_PADDING, SHORTCUT_INFO_PADDING, null); + + return bitmap; + } + /** * Returns a new {@link Drawable} that safely wraps and tints the provided drawable. */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java b/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java index f6413f0e37..1b3cb46b49 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/ServiceUtil.java @@ -7,6 +7,7 @@ import android.app.NotificationManager; import android.app.job.JobScheduler; import android.content.ClipboardManager; import android.content.Context; +import android.content.pm.ShortcutManager; import android.hardware.SensorManager; import android.hardware.display.DisplayManager; import android.location.LocationManager; @@ -31,6 +32,11 @@ public class ServiceUtil { return (InputMethodManager)context.getSystemService(Context.INPUT_METHOD_SERVICE); } + @RequiresApi(25) + public static @Nullable ShortcutManager getShortcutManager(@NonNull Context context) { + return ContextCompat.getSystemService(context, ShortcutManager.class); + } + public static WindowManager getWindowManager(Context context) { return (WindowManager) context.getSystemService(Activity.WINDOW_SERVICE); } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 364ac2088b..12de92ebce 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2386,6 +2386,9 @@ Network Force censorship Force the app to behave as if it is in a country where Signal is censored. + Conversations and Shortcuts + Delete all dynamic shortcuts + Click to delete all dynamic shortcuts diff --git a/app/src/main/res/xml/preferences_internal.xml b/app/src/main/res/xml/preferences_internal.xml index 9f31a6f432..93feafce20 100644 --- a/app/src/main/res/xml/preferences_internal.xml +++ b/app/src/main/res/xml/preferences_internal.xml @@ -104,4 +104,25 @@ + + + + + + + + + + + +