diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index f48c895c0d..db77de4d56 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -8,7 +8,11 @@ package org.thoughtcrime.securesms.conversation.v2 import android.Manifest import android.annotation.SuppressLint import android.app.ActivityOptions +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.graphics.Bitmap import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter @@ -37,6 +41,7 @@ import androidx.appcompat.view.ActionMode import androidx.core.app.ActivityCompat import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat +import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.ViewCompat import androidx.core.view.doOnNextLayout import androidx.core.view.doOnPreDraw @@ -63,6 +68,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.signal.core.util.PendingIntentFlags import org.signal.core.util.Result import org.signal.core.util.ThreadUtil import org.signal.core.util.concurrent.LifecycleDisposable @@ -72,6 +78,7 @@ import org.signal.core.util.logging.Log import org.signal.core.util.orNull import org.signal.libsignal.protocol.InvalidMessageException import org.thoughtcrime.securesms.BlockUnblockDialog +import org.thoughtcrime.securesms.GroupMembersDialog import org.thoughtcrime.securesms.LoggingFragment import org.thoughtcrime.securesms.MainActivity import org.thoughtcrime.securesms.MuteDialog @@ -232,6 +239,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) companion object { private val TAG = Log.tag(ConversationFragment::class.java) + private const val ACTION_PINNED_SHORTCUT = "action_pinned_shortcut" } private val args: ConversationIntents.Args by lazy { @@ -291,6 +299,7 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) private var animationsAllowed = false private var actionMode: ActionMode? = null + private var pinnedShortcutReceiver: BroadcastReceiver? = null private val jumpAndPulseScrollStrategy = object : ScrollToPositionDelegate.ScrollStrategy { override fun performScroll(recyclerView: RecyclerView, layoutManager: LinearLayoutManager, position: Int, smooth: Boolean) { @@ -388,6 +397,13 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) EventBus.getDefault().unregister(this) } + override fun onDestroyView() { + super.onDestroyView() + if (pinnedShortcutReceiver != null) { + requireActivity().unregisterReceiver(pinnedShortcutReceiver) + } + } + private fun observeConversationThread() { var firstRender = true disposables += viewModel @@ -1783,7 +1799,29 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } override fun handleAddShortcut() { - // TODO [cfv2] - ("Not yet implemented") + val recipient: Recipient = viewModel.recipientSnapshot ?: return + Log.i(TAG, "Creating home screen shortcut for recipient ${recipient.id}") + + if (pinnedShortcutReceiver == null) { + pinnedShortcutReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + toast( + toastTextId = R.string.ConversationActivity_added_to_home_screen, + toastDuration = Toast.LENGTH_LONG + ) + } + } + + requireActivity().registerReceiver(pinnedShortcutReceiver, IntentFilter(ACTION_PINNED_SHORTCUT)) + } + + viewModel.getContactPhotoIcon(requireContext(), GlideApp.with(this@ConversationFragment)) + .subscribe { infoCompat -> + val intent = Intent(ACTION_PINNED_SHORTCUT) + val callback = PendingIntent.getBroadcast(requireContext(), 902, intent, PendingIntentFlags.mutable()) + ShortcutManagerCompat.requestPinShortcut(requireContext(), infoCompat, callback.intentSender) + } + .addTo(disposables) } override fun handleSearch() { @@ -1801,16 +1839,13 @@ class ConversationFragment : LoggingFragment(R.layout.v2_conversation_fragment) } override fun handleDisplayGroupRecipients() { - // TODO [cfv2] - ("Not yet implemented") + val recipientSnapshot = viewModel.recipientSnapshot?.takeIf { it.isGroup } ?: return + GroupMembersDialog(requireActivity(), recipientSnapshot).display() } - override fun handleDistributionBroadcastEnabled(menuItem: MenuItem) { - // TODO [cfv2] - ("Not yet implemented") - } + override fun handleDistributionBroadcastEnabled(menuItem: MenuItem) = error("This fragment does not support this action.") - override fun handleDistributionConversationEnabled(menuItem: MenuItem) { - // TODO [cfv2] - ("Not yet implemented") - } + override fun handleDistributionConversationEnabled(menuItem: MenuItem) = error("This fragment does not support this action.") override fun handleManageGroup() { val recipient = viewModel.recipientSnapshot ?: return diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt index 12aea834f2..497e89f629 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationRepository.kt @@ -6,22 +6,32 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context +import android.graphics.Bitmap +import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.graphics.drawable.IconCompat +import com.bumptech.glide.request.target.CustomTarget +import com.bumptech.glide.request.transition.Transition import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.core.SingleEmitter import io.reactivex.rxjava3.schedulers.Schedulers import org.signal.core.util.StreamUtil import org.signal.core.util.concurrent.SignalExecutors +import org.signal.core.util.dp import org.signal.core.util.logging.Log import org.signal.core.util.toOptional import org.signal.libsignal.protocol.InvalidMessageException import org.signal.paging.PagedData import org.signal.paging.PagingConfig +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.ShortcutLauncherActivity import org.thoughtcrime.securesms.components.reminder.BubbleOptOutReminder import org.thoughtcrime.securesms.components.reminder.ExpiredBuildReminder import org.thoughtcrime.securesms.components.reminder.GroupsV1MigrationSuggestionsReminder @@ -57,6 +67,7 @@ import org.thoughtcrime.securesms.jobs.MultiDeviceViewOnceOpenJob import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.messagerequests.MessageRequestState +import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.OutgoingMessage import org.thoughtcrime.securesms.mms.PartAuthority import org.thoughtcrime.securesms.mms.QuoteModel @@ -67,6 +78,8 @@ import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientFormattingException import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.sms.MessageSender +import org.thoughtcrime.securesms.util.BitmapUtil +import org.thoughtcrime.securesms.util.DrawableUtil import org.thoughtcrime.securesms.util.SignalLocalMetrics import org.thoughtcrime.securesms.util.Util import org.thoughtcrime.securesms.util.hasTextSlide @@ -394,6 +407,91 @@ class ConversationRepository( .joinToString("\n") } + fun getRecipientContactPhotoBitmap(context: Context, glideRequests: GlideRequests, recipient: Recipient): Single { + val fallback = recipient.fallbackContactPhoto.asDrawable(context, recipient.avatarColor, false) + + return Single + .create { emitter -> + glideRequests + .asBitmap() + .load(recipient.contactPhoto) + .error(fallback) + .into(ContactPhotoTarget(recipient.id, emitter)) + } + .flatMap(ContactPhotoResult::transformToFinalBitmap) + .map(IconCompat::createWithAdaptiveBitmap) + .map { + val name = if (recipient.isSelf) context.getString(R.string.note_to_self) else recipient.getDisplayName(context) + + ShortcutInfoCompat.Builder(context, "${recipient.id.serialize()}-${System.currentTimeMillis()}") + .setShortLabel(name) + .setIcon(it) + .setIntent(ShortcutLauncherActivity.createIntent(context, recipient.id)) + .build() + } + .subscribeOn(Schedulers.computation()) + } + + /** + * Glide target for a contact photo which expects an error drawable, and publishes + * the result to the given emitter. + * + * The recipient is only used for displaying logging information. + */ + private class ContactPhotoTarget( + private val recipientId: RecipientId, + private val emitter: SingleEmitter + ) : CustomTarget() { + override fun onLoadFailed(errorDrawable: Drawable?) { + requireNotNull(errorDrawable) + Log.w(TAG, "Utilizing fallback photo for shortcut for recipient $recipientId") + emitter.onSuccess(ContactPhotoResult.DrawableResult(errorDrawable)) + } + + override fun onResourceReady(resource: Bitmap, transition: Transition?) { + emitter.onSuccess(ContactPhotoResult.BitmapResult(resource)) + } + + override fun onLoadCleared(placeholder: Drawable?) = Unit + } + + /** + * The result of the Glide load to get a user's contact photo. This can then be transformed into + * something that the Android system likes via [transformToFinalBitmap] + */ + sealed interface ContactPhotoResult { + + companion object { + private val SHORTCUT_ICON_SIZE = if (Build.VERSION.SDK_INT >= 26) 72.dp else (48 + 16 * 2).dp + } + + class DrawableResult(private val drawable: Drawable) : ContactPhotoResult { + override fun transformToFinalBitmap(): Single { + return Single.create { + val bitmap = DrawableUtil.toBitmap(drawable, SHORTCUT_ICON_SIZE, SHORTCUT_ICON_SIZE) + it.setCancellable { + bitmap.recycle() + } + it.onSuccess(bitmap) + } + } + } + + class BitmapResult(private val bitmap: Bitmap) : ContactPhotoResult { + override fun transformToFinalBitmap(): Single { + return Single.create { + val bitmap = BitmapUtil.createScaledBitmap(bitmap, SHORTCUT_ICON_SIZE, SHORTCUT_ICON_SIZE) + it.setCancellable { + bitmap.recycle() + } + it.onSuccess(bitmap) + } + } + } + + fun transformToFinalBitmap(): Single + } + data class MessageCounts( val unread: Int, val mentions: Int diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 821f5b4a1a..93accefbf7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -7,6 +7,7 @@ package org.thoughtcrime.securesms.conversation.v2 import android.content.Context import android.net.Uri +import androidx.core.content.pm.ShortcutInfoCompat import androidx.lifecycle.ViewModel import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable @@ -43,6 +44,7 @@ import org.thoughtcrime.securesms.dependencies.ApplicationDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.messagerequests.MessageRequestRepository import org.thoughtcrime.securesms.messagerequests.MessageRequestState +import org.thoughtcrime.securesms.mms.GlideRequests import org.thoughtcrime.securesms.mms.QuoteModel import org.thoughtcrime.securesms.mms.SlideDeck import org.thoughtcrime.securesms.recipients.Recipient @@ -230,6 +232,12 @@ class ConversationViewModel( .addTo(disposables) } + fun getContactPhotoIcon(context: Context, glideRequests: GlideRequests): Single { + return recipient.firstOrError().flatMap { + repository.getRecipientContactPhotoBitmap(context, glideRequests, it) + } + } + fun requestMarkRead(timestamp: Long) { }