diff --git a/app/src/huawei/agconnect-services.json b/app/src/huawei/agconnect-services.json index 0c81d0477a..7fcb75e0f9 100644 --- a/app/src/huawei/agconnect-services.json +++ b/app/src/huawei/agconnect-services.json @@ -54,8 +54,8 @@ "channel_id":"" }, "edukit":{ - "edu_url":"edukit.edu.cloud.huawei.com.cn", - "dh_url":"edukit.edu.cloud.huawei.com.cn" + "edu_url":"edukit.cloud.huawei.com.cn", + "dh_url":"edukit.cloud.huawei.com.cn" }, "search":{ "url":"https://search-dre.cloud.huawei.com" diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt index dc7bf893d7..0a5c14fd42 100644 --- a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt +++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiPushService.kt @@ -20,8 +20,8 @@ class HuaweiPushService: HmsMessageService() { override fun onMessageReceived(message: RemoteMessage?) { Log.d(TAG, "onMessageReceived") - message?.dataOfMap?.takeIf { it.isNotEmpty() }?.let(pushReceiver::onPush) ?: - pushReceiver.onPush(message?.data?.let(Base64::decode)) + message?.dataOfMap?.takeIf { it.isNotEmpty() }?.let(pushReceiver::onPushDataReceived) ?: + pushReceiver.onPushDataReceived(message?.data?.let(Base64::decode)) } override fun onNewToken(token: String?) { diff --git a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt index 9d9b61ce9a..f0ce294596 100644 --- a/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt +++ b/app/src/huawei/kotlin/org/thoughtcrime/securesms/notifications/HuaweiTokenFetcher.kt @@ -24,6 +24,8 @@ class HuaweiTokenFetcher @Inject constructor( override suspend fun fetch(): String? = HmsInstanceId.getInstance(context).run { // https://developer.huawei.com/consumer/en/doc/development/HMS-Guides/push-basic-capability#h2-1576218800370 // getToken may return an empty string, if so HuaweiPushService#onNewToken will be called. - withContext(Dispatchers.IO) { getToken(APP_ID, TOKEN_SCOPE) } + withContext(Dispatchers.IO) { + getToken(APP_ID, TOKEN_SCOPE) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java index 2df6420de3..bc2b49da99 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/MediaPreviewActivity.java @@ -73,6 +73,7 @@ import org.session.libsession.utilities.recipients.Recipient; import org.session.libsession.utilities.recipients.RecipientModifiedListener; import org.session.libsignal.utilities.Log; import org.thoughtcrime.securesms.components.MediaView; +import org.thoughtcrime.securesms.components.dialogs.DeleteMediaPreviewDialog; import org.thoughtcrime.securesms.database.MediaDatabase.MediaRecord; import org.thoughtcrime.securesms.database.loaders.PagingMediaLoader; import org.thoughtcrime.securesms.database.model.MmsMessageRecord; diff --git a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt index 9ced2695df..d74174fecb 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt @@ -6,6 +6,7 @@ import com.google.protobuf.ByteString import org.greenrobot.eventbus.EventBus import org.session.libsession.database.MessageDataProvider import org.session.libsession.messaging.MessagingModuleConfiguration +import org.session.libsession.messaging.messages.MarkAsDeletedMessage import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.sending_receiving.attachments.Attachment import org.session.libsession.messaging.sending_receiving.attachments.AttachmentId @@ -198,7 +199,6 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) } override fun deleteMessages(messageIDs: List, threadId: Long, isSms: Boolean) { - val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() else DatabaseComponent.get(context).mmsDatabase() @@ -215,34 +215,32 @@ class DatabaseAttachmentProvider(context: Context, helper: SQLCipherOpenHelper) threadId?.let{ MessagingModuleConfiguration.shared.lastSentTimestampCache.delete(it, messages.map { it.timestamp }) } } - override fun updateMessageAsDeleted(messageId: Long, isSms: Boolean): Long { - val messagingDatabase: MessagingDatabase = - if (isSms) DatabaseComponent.get(context).smsDatabase() - else DatabaseComponent.get(context).mmsDatabase() - - val isOutgoing = messagingDatabase.isOutgoing(messageId) - messagingDatabase.markAsDeleted(messageId) - - if (isOutgoing) { - messagingDatabase.deleteMessage(messageId) - } - - return messageId - } - - override fun updateMessageAsDeleted(timestamp: Long, author: String): Long? { + override fun markMessageAsDeleted(timestamp: Long, author: String, displayedMessage: String) { val database = DatabaseComponent.get(context).mmsSmsDatabase() val address = Address.fromSerialized(author) - val message = database.getMessageFor(timestamp, address) ?: return null - updateMessageAsDeleted(message.id, !message.isMms) - val messagingDatabase: MessagingDatabase = if (message.isMms) DatabaseComponent.get(context).mmsDatabase() - else DatabaseComponent.get(context).smsDatabase() - messagingDatabase.markAsDeleted(message.id) - if (message.isOutgoing) { - messagingDatabase.deleteMessage(message.id) - } + val message = database.getMessageFor(timestamp, address) ?: return Log.w("", "Failed to find message to mark as deleted") - return message.id + markMessagesAsDeleted( + messages = listOf(MarkAsDeletedMessage( + messageId = message.id, + isOutgoing = message.isOutgoing + )), + isSms = !message.isMms, + displayedMessage = displayedMessage + ) + } + + override fun markMessagesAsDeleted( + messages: List, + isSms: Boolean, + displayedMessage: String + ) { + val messagingDatabase: MessagingDatabase = if (isSms) DatabaseComponent.get(context).smsDatabase() + else DatabaseComponent.get(context).mmsDatabase() + + messages.forEach { message -> + messagingDatabase.markAsDeleted(message.messageId, message.isOutgoing, displayedMessage) + } } override fun getServerHashForMessage(messageID: Long, mms: Boolean): String? = diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaDialog.kt similarity index 84% rename from app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaDialog.kt index 3d38857b50..54e9197da4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaDialog.kt @@ -1,7 +1,8 @@ -package org.thoughtcrime.securesms +package org.thoughtcrime.securesms.components.dialogs import android.content.Context import network.loki.messenger.R +import org.thoughtcrime.securesms.showSessionDialog class DeleteMediaDialog { companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaPreviewDialog.kt similarity index 84% rename from app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt rename to app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaPreviewDialog.kt index b8aad6c22a..a287ad0892 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/DeleteMediaPreviewDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/dialogs/DeleteMediaPreviewDialog.kt @@ -1,7 +1,8 @@ -package org.thoughtcrime.securesms +package org.thoughtcrime.securesms.components.dialogs import android.content.Context import network.loki.messenger.R +import org.thoughtcrime.securesms.showSessionDialog class DeleteMediaPreviewDialog { companion object { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt index af32b7f50f..65f90a134a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/menu/ActionItem.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.annotation.AttrRes import androidx.annotation.ColorInt + /** * Represents an action to be rendered */ diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt index fa0aede9e7..1ea8019522 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt @@ -33,9 +33,8 @@ import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.content.ContextCompat import androidx.core.view.isGone @@ -120,6 +119,7 @@ import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.contacts.SelectContactsActivity.Companion.selectedContactsKey import org.thoughtcrime.securesms.conversation.ConversationActionBarDelegate import org.thoughtcrime.securesms.conversation.disappearingmessages.DisappearingMessagesActivity +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.* import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnActionSelectedListener import org.thoughtcrime.securesms.conversation.v2.ConversationReactionOverlay.OnReactionSelectedListener import org.thoughtcrime.securesms.conversation.v2.MessageDetailActivity.Companion.MESSAGE_TIMESTAMP @@ -141,6 +141,7 @@ import org.thoughtcrime.securesms.conversation.v2.mention.MentionViewModel import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallback import org.thoughtcrime.securesms.conversation.v2.menus.ConversationActionModeCallbackDelegate import org.thoughtcrime.securesms.conversation.v2.menus.ConversationMenuHelper +import org.thoughtcrime.securesms.conversation.v2.messages.ControlMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageView import org.thoughtcrime.securesms.conversation.v2.messages.VisibleMessageViewDelegate import org.thoughtcrime.securesms.conversation.v2.search.SearchBottomBar @@ -184,8 +185,6 @@ import org.thoughtcrime.securesms.permissions.Permissions import org.thoughtcrime.securesms.reactions.ReactionsDialogFragment import org.thoughtcrime.securesms.reactions.any.ReactWithAnyEmojiDialogFragment import org.thoughtcrime.securesms.showSessionDialog -import org.thoughtcrime.securesms.ui.OpenURLAlertDialog -import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme import org.thoughtcrime.securesms.util.ActivityDispatcher import org.thoughtcrime.securesms.util.ConfigurationMessageUtilities import org.thoughtcrime.securesms.util.DateUtils @@ -257,8 +256,6 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe .get(LinkPreviewViewModel::class.java) } - private var openLinkDialogUrl: String? by mutableStateOf(null) - private val threadId: Long by lazy { var threadId = intent.getLongExtra(THREAD_ID, -1L) if (threadId == -1L) { @@ -361,9 +358,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe if (!viewModel.isMessageRequestThread && viewModel.canReactToMessages ) { - showEmojiPicker(message, view) + showConversationReaction(message, view) } else { - handleLongPress(message, position) + selectMessage(message, position) } }, onDeselect = { message, position -> @@ -424,7 +421,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe // endregion fun showOpenUrlDialog(url: String){ - openLinkDialogUrl = url + viewModel.onCommand(ShowOpenUrlDialog(url)) } // region Lifecycle @@ -437,16 +434,11 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe binding.dialogOpenUrl.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { - SessionMaterialTheme { - if(!openLinkDialogUrl.isNullOrEmpty()){ - OpenURLAlertDialog( - url = openLinkDialogUrl!!, - onDismissRequest = { - openLinkDialogUrl = null - } - ) - } - } + val dialogsState by viewModel.dialogsState.collectAsState() + ConversationV2Dialogs( + dialogsState = dialogsState, + sendCommand = viewModel::onCommand + ) } } @@ -456,7 +448,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe val recipient = viewModel.recipient val openGroup = recipient.let { viewModel.openGroup } if (recipient == null || (recipient.isCommunityRecipient && openGroup == null)) { - Toast.makeText(this, "This thread has been deleted.", Toast.LENGTH_LONG).show() + Toast.makeText(this, getString(R.string.conversationsDeleted), Toast.LENGTH_LONG).show() return finish() } @@ -675,6 +667,12 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe recyclerScrollState = newState } }) + + lifecycleScope.launch { + viewModel.isAdmin.collect{ + adapter.isAdmin = it + } + } } private fun scrollToMostRecentMessageIfWeShould() { @@ -912,6 +910,9 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe isVisible = state.showInput showMediaControls = state.enableInputMediaControls } + + // show or hide loading indicator + binding.loader.isVisible = uiState.showLoader } } } @@ -1356,7 +1357,7 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } // `position` is the adapter position; not the visual position - private fun handleLongPress(message: MessageRecord, position: Int) { + private fun selectMessage(message: MessageRecord, position: Int) { val actionMode = this.actionMode val actionModeCallback = ConversationActionModeCallback(adapter, viewModel.threadId, this) actionModeCallback.delegate = this @@ -1374,15 +1375,21 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } } - private fun showEmojiPicker(message: MessageRecord, visibleMessageView: VisibleMessageView) { + private fun showConversationReaction(message: MessageRecord, messageView: View) { + val messageContentView = when(messageView){ + is VisibleMessageView -> messageView.messageContentView + is ControlMessageView -> messageView.controlContentView + else -> null + } ?: return Log.w(TAG, "Failed to show reaction because the messageRecord is not of a known type: $messageView") + val messageContentBitmap = try { - visibleMessageView.messageContentView.drawToBitmap() + messageContentView.drawToBitmap() } catch (e: Exception) { Log.e("Loki", "Failed to show emoji picker", e) return } emojiPickerVisible = true - ViewUtil.hideKeyboard(this, visibleMessageView) + ViewUtil.hideKeyboard(this, messageView) binding.reactionsShade.isVisible = true binding.scrollToBottomButton.isVisible = false binding.conversationRecyclerView.suppressLayout(true) @@ -1404,14 +1411,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } }) - val topLeft = intArrayOf(0, 0).also { visibleMessageView.messageContentView.getLocationInWindow(it) } + val topLeft = intArrayOf(0, 0).also { messageContentView.getLocationInWindow(it) } val selectedConversationModel = SelectedConversationModel( messageContentBitmap, topLeft[0].toFloat(), topLeft[1].toFloat(), - visibleMessageView.messageContentView.width, + messageContentView.width, message.isOutgoing, - visibleMessageView.messageContentView + messageContentView ) reactionDelegate.show(this, message, selectedConversationModel, viewModel.blindedPublicKey) } @@ -2122,88 +2129,14 @@ class ConversationActivityV2 : PassphraseRequiredActionBarActivity(), InputBarDe } override fun selectMessages(messages: Set) { - handleLongPress(messages.first(), 0) //TODO: begin selection mode - } - - // The option to "Delete just for me" or "Delete for everyone" - private fun showDeleteOrDeleteForEveryoneInCommunityUI(messages: Set) { - val bottomSheet = DeleteOptionsBottomSheet() - bottomSheet.recipient = viewModel.recipient!! - bottomSheet.onDeleteForMeTapped = { - messages.forEach(viewModel::deleteLocally) - bottomSheet.dismiss() - endActionMode() - } - bottomSheet.onDeleteForEveryoneTapped = { - messages.forEach(viewModel::deleteForEveryone) - bottomSheet.dismiss() - endActionMode() - } - bottomSheet.onCancelTapped = { - bottomSheet.dismiss() - endActionMode() - } - bottomSheet.show(supportFragmentManager, bottomSheet.tag) - } - - private fun showDeleteLocallyUI(messages: Set) { - showSessionDialog { - title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count())) - text(resources.getString(R.string.deleteMessagesDescriptionDevice)) - button(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() } - cancelButton(::endActionMode) - } + selectMessage(messages.first(), 0) //TODO: begin selection mode } // Note: The messages in the provided set may be a single message, or multiple if there are a // group of selected messages. override fun deleteMessages(messages: Set) { - val recipient = viewModel.recipient - if (recipient == null) { - Log.w("ConversationActivityV2", "Asked to delete messages but could not obtain viewModel recipient - aborting.") - return - } - - val allSentByCurrentUser = messages.all { it.isOutgoing } - val allHasHash = messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null } - - // If the recipient is a community OR a Note-to-Self then we delete the message for everyone - if (recipient.isCommunityRecipient || recipient.isLocalNumber) { - showSessionDialog { - title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count())) - text(resources.getString(R.string.deleteMessageDescriptionEveryone)) - dangerButton(R.string.delete) { messages.forEach(viewModel::deleteForEveryone); endActionMode() } - cancelButton { endActionMode() } - } - // Otherwise if this is a 1-on-1 conversation we may decided to delete just for ourselves or delete for everyone - } else if ((allSentByCurrentUser || viewModel.isClosedGroupAdmin) && allHasHash) { - val bottomSheet = DeleteOptionsBottomSheet() - bottomSheet.recipient = recipient - bottomSheet.onDeleteForMeTapped = { - messages.forEach(viewModel::deleteLocally) - bottomSheet.dismiss() - endActionMode() - } - bottomSheet.onDeleteForEveryoneTapped = { - messages.forEach(viewModel::deleteForEveryone) - bottomSheet.dismiss() - endActionMode() - } - bottomSheet.onCancelTapped = { - bottomSheet.dismiss() - endActionMode() - } - bottomSheet.show(supportFragmentManager, bottomSheet.tag) - } - else // Finally, if this is a closed group and you are deleting someone else's message(s) then we can only delete locally. - { - showSessionDialog { - title(resources.getQuantityString(R.plurals.deleteMessage, messages.count(), messages.count())) - text(resources.getString(R.string.deleteMessageDescriptionDevice)) - dangerButton(R.string.delete) { messages.forEach(viewModel::deleteLocally); endActionMode() } - cancelButton(::endActionMode) - } - } + viewModel.handleMessagesDeletion(messages) + endActionMode() } override fun banUser(messages: Set) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt index 880dacb070..c83f71074d 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationAdapter.kt @@ -5,6 +5,7 @@ import android.database.Cursor import android.util.SparseArray import android.util.SparseBooleanArray import android.view.MotionEvent +import android.view.View import android.view.ViewGroup import androidx.annotation.WorkerThread import androidx.core.util.getOrDefault @@ -35,7 +36,7 @@ class ConversationAdapter( private val isReversed: Boolean, private val onItemPress: (MessageRecord, Int, VisibleMessageView, MotionEvent) -> Unit, private val onItemSwipeToReply: (MessageRecord, Int) -> Unit, - private val onItemLongPress: (MessageRecord, Int, VisibleMessageView) -> Unit, + private val onItemLongPress: (MessageRecord, Int, View) -> Unit, private val onDeselect: (MessageRecord, Int) -> Unit, private val onAttachmentNeedsDownload: (DatabaseAttachment) -> Unit, private val glide: RequestManager, @@ -44,6 +45,7 @@ class ConversationAdapter( private val messageDB by lazy { DatabaseComponent.get(context).mmsSmsDatabase() } private val contactDB by lazy { DatabaseComponent.get(context).sessionContactDatabase() } var selectedItems = mutableSetOf() + var isAdmin: Boolean = false private var searchQuery: String? = null var visibleMessageViewDelegate: VisibleMessageViewDelegate? = null @@ -155,12 +157,18 @@ class ConversationAdapter( } else { visibleMessageView.onPress = null visibleMessageView.onSwipeToReply = null - visibleMessageView.onLongPress = null + // you can long press on "marked as deleted" messages + visibleMessageView.onLongPress = + { onItemLongPress(message, viewHolder.adapterPosition, visibleMessageView) } } } is ControlMessageViewHolder -> { - viewHolder.view.bind(message, messageBefore) + viewHolder.view.bind( + message = message, + previous = messageBefore, + longPress = { onItemLongPress(message, viewHolder.adapterPosition, viewHolder.view) } + ) } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt index d445d002cb..a76dead344 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationReactionOverlay.kt @@ -21,6 +21,7 @@ import android.widget.TextView import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.ContextCompat import androidx.core.view.doOnLayout +import androidx.core.view.isVisible import androidx.vectordrawable.graphics.drawable.AnimatorInflaterCompat import com.squareup.phrase.Phrase import dagger.hilt.android.AndroidEntryPoint @@ -222,7 +223,6 @@ class ConversationReactionOverlay : FrameLayout { endScale = spaceAvailableForItem / conversationItemSnapshot.height endX += Util.halfOffsetFromScale(conversationItemSnapshot.width, endScale) * if (isMessageOnLeft) -1 else 1 endY = spaceForReactionBar - Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) - val contextMenuTop = endY + conversationItemSnapshot.height * endScale reactionBarBackgroundY = reactionBarTopPadding //getReactionBarOffsetForTouch(selectedConversationModel.getBubbleY(), contextMenuTop + Util.halfOffsetFromScale(conversationItemSnapshot.getHeight(), endScale), menuPadding, reactionBarOffset, reactionBarHeight, reactionBarTopPadding, endY); endApparentTop = endY + Util.halfOffsetFromScale(conversationItemSnapshot.height, endScale) } else { @@ -271,11 +271,17 @@ class ConversationReactionOverlay : FrameLayout { revealAnimatorSet.start() if (isWideLayout) { val scrubberRight = scrubberX + scrubberWidth - val offsetX = if (isMessageOnLeft) scrubberRight + menuPadding else scrubberX - contextMenu.getMaxWidth() - menuPadding + val offsetX = when { + isMessageOnLeft -> scrubberRight + menuPadding + else -> scrubberX - contextMenu.getMaxWidth() - menuPadding + } contextMenu.show(offsetX.toInt(), Math.min(backgroundView.y, (overlayHeight - contextMenu.getMaxHeight()).toFloat()).toInt()) } else { val contentX = if (isMessageOnLeft) scrubberHorizontalMargin.toFloat() else selectedConversationModel.bubbleX - val offsetX = if (isMessageOnLeft) contentX else -contextMenu.getMaxWidth() + contentX + bubbleWidth + val offsetX = when { + isMessageOnLeft -> contentX + else -> -contextMenu.getMaxWidth() + contentX + bubbleWidth + } val menuTop = endApparentTop + conversationItemSnapshot.height * endScale contextMenu.show(offsetX.toInt(), (menuTop + menuPadding).toInt()) } @@ -526,19 +532,30 @@ class ConversationReactionOverlay : FrameLayout { val recipient = get(context).threadDatabase().getRecipientForThreadId(message.threadId) ?: return emptyList() val userPublicKey = getLocalNumber(context)!! + + // control messages and "marked as deleted" messages can only delete + val isDeleteOnly = message.isDeleted || message.isControlMessage + // Select message - items += ActionItem(R.attr.menu_select_icon, R.string.select, { handleActionItemClicked(Action.SELECT) }, R.string.AccessibilityId_select) + if(!isDeleteOnly) { + items += ActionItem( + R.attr.menu_select_icon, + R.string.select, + { handleActionItemClicked(Action.SELECT) }, + R.string.AccessibilityId_select + ) + } // Reply val canWrite = openGroup == null || openGroup.canWrite - if (canWrite && !message.isPending && !message.isFailed && !message.isOpenGroupInvitation) { + if (canWrite && !message.isPending && !message.isFailed && !message.isOpenGroupInvitation && !isDeleteOnly) { items += ActionItem(R.attr.menu_reply_icon, R.string.reply, { handleActionItemClicked(Action.REPLY) }, R.string.AccessibilityId_reply) } // Copy message text - if (!containsControlMessage && hasText) { + if (!containsControlMessage && hasText && !isDeleteOnly) { items += ActionItem(R.attr.menu_copy_icon, R.string.copy, { handleActionItemClicked(Action.COPY_MESSAGE) }) } // Copy Account ID - if (!recipient.isCommunityRecipient && message.isIncoming) { + if (!recipient.isCommunityRecipient && message.isIncoming && !isDeleteOnly) { items += ActionItem(R.attr.menu_copy_icon, R.string.accountIDCopy, { handleActionItemClicked(Action.COPY_ACCOUNT_ID) }) } // Delete message @@ -547,15 +564,20 @@ class ConversationReactionOverlay : FrameLayout { R.string.AccessibilityId_deleteMessage, message.subtitle, ThemeUtil.getThemedColor(context, R.attr.danger)) } // Ban user - if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { + if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey) && !isDeleteOnly) { items += ActionItem(R.attr.menu_block_icon, R.string.banUser, { handleActionItemClicked(Action.BAN_USER) }) } // Ban and delete all - if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey)) { + if (userCanBanSelectedUsers(context, message, openGroup, userPublicKey, blindedPublicKey) && !isDeleteOnly) { items += ActionItem(R.attr.menu_trash_icon, R.string.banDeleteAll, { handleActionItemClicked(Action.BAN_AND_DELETE_ALL) }) } // Message detail - items += ActionItem(R.attr.menu_info_icon, R.string.messageInfo, { handleActionItemClicked(Action.VIEW_INFO) }) + if(!isDeleteOnly) { + items += ActionItem( + R.attr.menu_info_icon, + R.string.messageInfo, + { handleActionItemClicked(Action.VIEW_INFO) }) + } // Resend if (message.isFailed) { items += ActionItem(R.attr.menu_reply_icon, R.string.resend, { handleActionItemClicked(Action.RESEND) }) @@ -565,7 +587,7 @@ class ConversationReactionOverlay : FrameLayout { items += ActionItem(R.attr.menu_reply_icon, R.string.resync, { handleActionItemClicked(Action.RESYNC) }) } // Save media.. - if (message.isMms) { + if (message.isMms && !isDeleteOnly) { // ..but only provide the save option if the there is a media attachment which has finished downloading. val mmsMessage = message as MediaMmsMessageRecord if (mmsMessage.containsMediaSlide() && !mmsMessage.isMediaPending) { @@ -576,8 +598,10 @@ class ConversationReactionOverlay : FrameLayout { ) } } - backgroundView.visibility = VISIBLE - foregroundView.visibility = VISIBLE + + // deleted messages have no emoji reactions + backgroundView.isVisible = !isDeleteOnly + foregroundView.isVisible = !isDeleteOnly return items } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt new file mode 100644 index 0000000000..26775f01c2 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationV2Dialogs.kt @@ -0,0 +1,219 @@ +package org.thoughtcrime.securesms.conversation.v2 + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import network.loki.messenger.R +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.HideDeleteAllDevicesDialog +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.HideDeleteEveryoneDialog +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.MarkAsDeletedForEveryone +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.MarkAsDeletedLocally +import org.thoughtcrime.securesms.conversation.v2.ConversationViewModel.Commands.ShowOpenUrlDialog +import org.thoughtcrime.securesms.ui.AlertDialog +import org.thoughtcrime.securesms.ui.DialogButtonModel +import org.thoughtcrime.securesms.ui.GetString +import org.thoughtcrime.securesms.ui.OpenURLAlertDialog +import org.thoughtcrime.securesms.ui.RadioOption +import org.thoughtcrime.securesms.ui.components.TitledRadioButton +import org.thoughtcrime.securesms.ui.theme.LocalColors +import org.thoughtcrime.securesms.ui.theme.LocalDimensions +import org.thoughtcrime.securesms.ui.theme.LocalType +import org.thoughtcrime.securesms.ui.theme.PreviewTheme +import org.thoughtcrime.securesms.ui.theme.SessionMaterialTheme + +@Composable +fun ConversationV2Dialogs( + dialogsState: ConversationViewModel.DialogsState, + sendCommand: (ConversationViewModel.Commands) -> Unit +){ + SessionMaterialTheme { + // open link confirmation + if(!dialogsState.openLinkDialogUrl.isNullOrEmpty()){ + OpenURLAlertDialog( + url = dialogsState.openLinkDialogUrl, + onDismissRequest = { + // hide dialog + sendCommand(ShowOpenUrlDialog(null)) + } + ) + } + + // delete message(s) for everyone + if(dialogsState.deleteEveryone != null){ + var deleteForEveryone by remember { mutableStateOf(dialogsState.deleteEveryone.defaultToEveryone)} + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideDeleteEveryoneDialog) + }, + title = pluralStringResource( + R.plurals.deleteMessage, + dialogsState.deleteEveryone.messages.size, + dialogsState.deleteEveryone.messages.size + ), + text = pluralStringResource( + R.plurals.deleteMessageConfirm, + dialogsState.deleteEveryone.messages.size, + dialogsState.deleteEveryone.messages.size + ), + content = { + // add warning text, if any + dialogsState.deleteEveryone.warning?.let { + Text( + text = it, + textAlign = TextAlign.Center, + style = LocalType.current.small, + color = LocalColors.current.warning, + modifier = Modifier.padding( + top = LocalDimensions.current.xxxsSpacing, + bottom = LocalDimensions.current.xxsSpacing + ) + ) + } + + TitledRadioButton( + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xxsSpacing, + vertical = 0.dp + ), + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.deleteMessageDeviceOnly)), + selected = !deleteForEveryone + ) + ) { + deleteForEveryone = false + } + + TitledRadioButton( + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xxsSpacing, + vertical = 0.dp + ), + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.deleteMessageEveryone)), + selected = deleteForEveryone, + enabled = dialogsState.deleteEveryone.everyoneEnabled + ) + ) { + deleteForEveryone = true + } + }, + buttons = listOf( + DialogButtonModel( + text = GetString(stringResource(id = R.string.delete)), + color = LocalColors.current.danger, + onClick = { + // delete messages based on chosen option + sendCommand( + if(deleteForEveryone) MarkAsDeletedForEveryone( + dialogsState.deleteEveryone.copy(defaultToEveryone = deleteForEveryone) + ) + else MarkAsDeletedLocally(dialogsState.deleteEveryone.messages) + ) + } + ), + DialogButtonModel( + GetString(stringResource(R.string.cancel)) + ) + ) + ) + } + + // delete message(s) for all my devices + if(dialogsState.deleteAllDevices != null){ + var deleteAllDevices by remember { mutableStateOf(dialogsState.deleteAllDevices.defaultToEveryone) } + + AlertDialog( + onDismissRequest = { + // hide dialog + sendCommand(HideDeleteAllDevicesDialog) + }, + title = pluralStringResource( + R.plurals.deleteMessage, + dialogsState.deleteAllDevices.messages.size, + dialogsState.deleteAllDevices.messages.size + ), + text = pluralStringResource( + R.plurals.deleteMessageConfirm, + dialogsState.deleteAllDevices.messages.size, + dialogsState.deleteAllDevices.messages.size + ), + content = { + TitledRadioButton( + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xxsSpacing, + vertical = 0.dp + ), + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.deleteMessageDeviceOnly)), + selected = !deleteAllDevices + ) + ) { + deleteAllDevices = false + } + + TitledRadioButton( + contentPadding = PaddingValues( + horizontal = LocalDimensions.current.xxsSpacing, + vertical = 0.dp + ), + option = RadioOption( + value = Unit, + title = GetString(stringResource(R.string.deleteMessageDevicesAll)), + selected = deleteAllDevices + ) + ) { + deleteAllDevices = true + } + }, + buttons = listOf( + DialogButtonModel( + text = GetString(stringResource(id = R.string.delete)), + color = LocalColors.current.danger, + onClick = { + // delete messages based on chosen option + sendCommand( + if(deleteAllDevices) MarkAsDeletedForEveryone( + dialogsState.deleteAllDevices.copy(defaultToEveryone = deleteAllDevices) + ) + else MarkAsDeletedLocally(dialogsState.deleteAllDevices.messages) + ) + } + ), + DialogButtonModel( + GetString(stringResource(R.string.cancel)) + ) + ) + ) + } + + } +} + +@Preview +@Composable +fun PreviewURLDialog(){ + PreviewTheme { + ConversationV2Dialogs( + dialogsState = ConversationViewModel.DialogsState( + openLinkDialogUrl = "https://google.com" + ), + sendCommand = {} + ) + } +} 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 73fe8c60d3..d864904d1b 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 @@ -1,7 +1,9 @@ package org.thoughtcrime.securesms.conversation.v2 +import android.app.Application import android.content.Context import androidx.annotation.StringRes +import android.widget.Toast import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope @@ -12,13 +14,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import network.loki.messenger.R import network.loki.messenger.libsession_util.util.GroupMember import org.session.libsession.database.MessageDataProvider @@ -29,27 +31,37 @@ import org.session.libsession.messaging.open_groups.OpenGroupApi import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAttachment import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.Address +import org.session.libsession.utilities.Address.Companion.fromSerialized +import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.recipients.Recipient +import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.getType import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.Log import org.session.libsignal.utilities.AccountId import org.thoughtcrime.securesms.audio.AudioSlidePlayer import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.database.LokiMessageDatabase +import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.groups.OpenGroupManager +import org.thoughtcrime.securesms.mms.AudioSlide import org.thoughtcrime.securesms.repository.ConversationRepository import java.util.UUID class ConversationViewModel( val threadId: Long, val edKeyPair: KeyPair?, + private val application: Application, private val repository: ConversationRepository, private val storage: StorageProtocol, private val messageDataProvider: MessageDataProvider, private val groupDb: GroupDatabase, private val threadDb: ThreadDatabase, + private val lokiMessageDb: LokiMessageDatabase, + private val textSecurePreferences: TextSecurePreferences ) : ViewModel() { val showSendAfterApprovalText: Boolean @@ -58,8 +70,44 @@ class ConversationViewModel( private val _uiState = MutableStateFlow(ConversationUiState()) val uiState: StateFlow get() = _uiState + private val _dialogsState = MutableStateFlow(DialogsState()) + val dialogsState: StateFlow = _dialogsState + + private val _isAdmin = MutableStateFlow(false) + val isAdmin: StateFlow = _isAdmin + private var _recipient: RetrieveOnce = RetrieveOnce { - repository.maybeGetRecipientForThreadId(threadId) + val conversation = repository.maybeGetRecipientForThreadId(threadId) + + // set admin from current conversation + val conversationType = conversation?.getType() + // Determining is the current user is an admin will depend on the kind of conversation we are in + _isAdmin.value = when(conversationType) { + // for Groups V2 + MessageType.GROUPS_V2 -> { + //todo GROUPS V2 add logic where code is commented to determine if user is an admin + false // FANCHAO - properly set up admin for groups v2 here + } + + // for legacy groups, check if the user created the group + MessageType.LEGACY_GROUP -> { + // for legacy groups, we check if the current user is the one who created the group + run { + val localUserAddress = + textSecurePreferences.getLocalNumber() ?: return@run false + val group = storage.getGroup(conversation.address.toGroupString()) + group?.admins?.contains(fromSerialized(localUserAddress)) ?: false + } + } + + // for communities the the `isUserModerator` field + MessageType.COMMUNITY -> isUserCommunityManager() + + // false in other cases + else -> false + } + + conversation } val expirationConfiguration: ExpirationConfiguration? get() = storage.getExpirationConfiguration(threadId) @@ -274,50 +322,409 @@ class ConversationViewModel( repository.deleteThread(threadId) } - fun deleteLocally(message: MessageRecord) { - stopPlayingAudioMessage(message) - val recipient = recipient ?: return Log.w("Loki", "Recipient was null for delete locally action") - repository.deleteLocally(recipient, message) + fun handleMessagesDeletion(messages: Set){ + val conversation = recipient + if (conversation == null) { + Log.w("ConversationActivityV2", "Asked to delete messages but could not obtain viewModel recipient - aborting.") + return + } + + viewModelScope.launch(Dispatchers.IO) { + val allSentByCurrentUser = messages.all { it.isOutgoing } + + val conversationType = conversation.getType() + + // hashes are required if wanting to delete messages from the 'storage server' + // They are not required for communities OR if all messages are outgoing + // also we can only delete deleted messages (marked as deleted) locally + val canDeleteForEveryone = messages.all{ !it.isDeleted && !it.isControlMessage } && ( + messages.all { it.isOutgoing } || + conversationType == MessageType.COMMUNITY || + messages.all { lokiMessageDb.getMessageServerHash(it.id, it.isMms) != null + }) + + // There are three types of dialogs for deletion: + // 1- Delete on device only OR all devices - Used for Note to self + // 2- Delete on device only OR for everyone - Used for 'admins' or a user's own messages, as long as the message have a server hash + // 3- Delete on device only - Used otherwise + when { + // the conversation is a note to self + conversationType == MessageType.NOTE_TO_SELF -> { + _dialogsState.update { + it.copy(deleteAllDevices = DeleteForEveryoneDialogData( + messages = messages, + defaultToEveryone = false, + everyoneEnabled = true, + messageType = conversationType + ) + ) + } + } + + // If the user is an admin or is interacting with their own message And are allowed to delete for everyone + (isAdmin.value || allSentByCurrentUser) && canDeleteForEveryone -> { + _dialogsState.update { + it.copy( + deleteEveryone = DeleteForEveryoneDialogData( + messages = messages, + defaultToEveryone = isAdmin.value, + everyoneEnabled = true, + messageType = conversationType + ) + ) + } + } + + // for non admins, users interacting with someone else's message, or control messages + else -> { + _dialogsState.update { + it.copy( + deleteEveryone = DeleteForEveryoneDialogData( + messages = messages, + defaultToEveryone = false, + everyoneEnabled = false, // disable 'delete for everyone' - can only delete locally in this case + messageType = conversationType, + warning = application.resources.getQuantityString( + R.plurals.deleteMessageWarning, messages.count(), messages.count() + ) + ) + ) + } + } + } + } } + /** + * This delete the message locally only. + * Attachments and other related data will be removed from the db. + * If the messages were already marked as deleted they will be removed fully from the db, + * otherwise they will appear as a special type of message + * that says something like "This message was deleted" + */ + fun deleteLocally(messages: Set) { + // make sure to stop audio messages, if any + messages.filterIsInstance() + .mapNotNull { it.slideDeck.audioSlide } + .forEach(::stopMessageAudio) + + // if the message was already marked as deleted or control messages, remove it from the db instead + if(messages.all { it.isDeleted || it.isControlMessage }){ + // Remove the message locally (leave nothing behind) + repository.deleteMessages(messages = messages, threadId = threadId) + } else { + // only mark as deleted (message remains behind with "This message was deleted on this device" ) + repository.markAsDeletedLocally( + messages = messages, + displayedMessage = application.getString(R.string.deleteMessageDeletedLocally) + ) + } + + // show confirmation toast + Toast.makeText( + application, + application.resources.getQuantityString(R.plurals.deleteMessageDeleted, messages.count(), messages.count()), + Toast.LENGTH_SHORT + ).show() + } + + /** + * This will mark the messages as deleted, for everyone. + * Attachments and other related data will be removed from the db, + * but the messages themselves won't be removed from the db. + * Instead they will appear as a special type of message + * that says something like "This message was deleted" + */ + private fun markAsDeletedForEveryone( + data: DeleteForEveryoneDialogData + ) = viewModelScope.launch { + val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.") + + // make sure to stop audio messages, if any + data.messages.filterIsInstance() + .mapNotNull { it.slideDeck.audioSlide } + .forEach(::stopMessageAudio) + + // the exact logic for this will depend on the messages type + when(data.messageType){ + MessageType.NOTE_TO_SELF -> markAsDeletedForEveryoneNoteToSelf(data) + MessageType.ONE_ON_ONE -> markAsDeletedForEveryone1On1(data) + MessageType.LEGACY_GROUP -> markAsDeletedForEveryoneLegacyGroup(data.messages) + MessageType.GROUPS_V2 -> markAsDeletedForEveryoneGroupsV2(data) + MessageType.COMMUNITY -> markAsDeletedForEveryoneCommunity(data) + } + } + + private fun markAsDeletedForEveryoneNoteToSelf(data: DeleteForEveryoneDialogData){ + if(recipient == null) return showMessage(application.getString(R.string.errorUnknown)) + + viewModelScope.launch(Dispatchers.IO) { + // show a loading indicator + _uiState.update { it.copy(showLoader = true) } + + // delete remotely + try { + repository.deleteNoteToSelfMessagesRemotely(threadId, recipient!!, data.messages) + + // When this is done we simply need to remove the message locally (leave nothing behind) + repository.deleteMessages(messages = data.messages, threadId = threadId) + + // show confirmation toast + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + data.messages.count(), + data.messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.w("Loki", "FAILED TO delete messages ${data.messages} ") + // failed to delete - show a toast and get back on the modal + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + data.messages.size, + data.messages.size + ), Toast.LENGTH_SHORT + ).show() + } + + _dialogsState.update { it.copy(deleteEveryone = data) } + } + + // hide loading indicator + _uiState.update { it.copy(showLoader = false) } + } + } + + private fun markAsDeletedForEveryone1On1(data: DeleteForEveryoneDialogData){ + if(recipient == null) return showMessage(application.getString(R.string.errorUnknown)) + + viewModelScope.launch(Dispatchers.IO) { + // show a loading indicator + _uiState.update { it.copy(showLoader = true) } + + // delete remotely + try { + repository.delete1on1MessagesRemotely(threadId, recipient!!, data.messages) + + // When this is done we simply need to remove the message locally + repository.markAsDeletedLocally( + messages = data.messages, + displayedMessage = application.getString(R.string.deleteMessageDeletedGlobally) + ) + + // show confirmation toast + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + data.messages.count(), + data.messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.w("Loki", "FAILED TO delete messages ${data.messages} ") + // failed to delete - show a toast and get back on the modal + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + data.messages.size, + data.messages.size + ), Toast.LENGTH_SHORT + ).show() + } + + _dialogsState.update { it.copy(deleteEveryone = data) } + } + + // hide loading indicator + _uiState.update { it.copy(showLoader = false) } + } + } + + private fun markAsDeletedForEveryoneLegacyGroup(messages: Set){ + if(recipient == null) return showMessage(application.getString(R.string.errorUnknown)) + + viewModelScope.launch(Dispatchers.IO) { + // delete remotely + try { + repository.deleteLegacyGroupMessagesRemotely(recipient!!, messages) + + // When this is done we simply need to remove the message locally + repository.markAsDeletedLocally( + messages = messages, + displayedMessage = application.getString(R.string.deleteMessageDeletedGlobally) + ) + + // show confirmation toast + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + messages.count(), + messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.w("Loki", "FAILED TO delete messages ${messages} ") + // failed to delete - show a toast and get back on the modal + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + messages.size, + messages.size + ), Toast.LENGTH_SHORT + ).show() + } + } + } + } + + private fun markAsDeletedForEveryoneGroupsV2(data: DeleteForEveryoneDialogData){ + viewModelScope.launch(Dispatchers.IO) { + // show a loading indicator + _uiState.update { it.copy(showLoader = true) } + + //todo GROUPS V2 - uncomment below and use Fanchao's method to delete a group V2 + try { + //repository.callMethodFromFanchao(threadId, recipient, data.messages) + + // the repo will handle the internal logic (calling `/delete` on the swarm + // and sending 'GroupUpdateDeleteMemberContentMessage' + // When this is done we simply need to remove the message locally + repository.markAsDeletedLocally( + messages = data.messages, + displayedMessage = application.getString(R.string.deleteMessageDeletedGlobally) + ) + + // show confirmation toast + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + data.messages.count(), data.messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.w("Loki", "FAILED TO delete messages ${data.messages} ") + // failed to delete - show a toast and get back on the modal + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + data.messages.size, + data.messages.size + ), Toast.LENGTH_SHORT + ).show() + } + + _dialogsState.update { it.copy(deleteAllDevices = data) } + } + + // hide loading indicator + _uiState.update { it.copy(showLoader = false) } + } + } + + private fun markAsDeletedForEveryoneCommunity(data: DeleteForEveryoneDialogData){ + viewModelScope.launch(Dispatchers.IO) { + // show a loading indicator + _uiState.update { it.copy(showLoader = true) } + + // delete remotely + try { + repository.deleteCommunityMessagesRemotely(threadId, data.messages) + + // When this is done we simply need to remove the message locally + repository.markAsDeletedLocally( + messages = data.messages, + displayedMessage = application.getString(R.string.deleteMessageDeletedGlobally) + ) + + // show confirmation toast + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageDeleted, + data.messages.count(), + data.messages.count() + ), + Toast.LENGTH_SHORT + ).show() + } + } catch (e: Exception) { + Log.w("Loki", "FAILED TO delete messages ${data.messages} ") + // failed to delete - show a toast and get back on the modal + withContext(Dispatchers.Main) { + Toast.makeText( + application, + application.resources.getQuantityString( + R.plurals.deleteMessageFailed, + data.messages.size, + data.messages.size + ), Toast.LENGTH_SHORT + ).show() + } + + _dialogsState.update { it.copy(deleteEveryone = data) } + } + + // hide loading indicator + _uiState.update { it.copy(showLoader = false) } + } + } + + private fun isUserCommunityManager() = openGroup?.let { openGroup -> + val userPublicKey = textSecurePreferences.getLocalNumber() ?: return@let false + OpenGroupManager.isUserModerator(application, openGroup.id, userPublicKey, blindedPublicKey) + } ?: false + /** * Stops audio player if its current playing is the one given in the message. */ - private fun stopPlayingAudioMessage(message: MessageRecord) { + private fun stopMessageAudio(message: MessageRecord) { val mmsMessage = message as? MmsMessageRecord ?: return val audioSlide = mmsMessage.slideDeck.audioSlide ?: return + stopMessageAudio(audioSlide) + } + private fun stopMessageAudio(audioSlide: AudioSlide) { AudioSlidePlayer.getInstance()?.takeIf { it.audioSlide == audioSlide }?.stop() } - fun deleteForEveryone(message: MessageRecord) = viewModelScope.launch { - val recipient = recipient ?: return@launch Log.w("Loki", "Recipient was null for delete for everyone - aborting delete operation.") - stopPlayingAudioMessage(message) - - repository.deleteForEveryone(threadId, recipient, message) - .onSuccess { - Log.d("Loki", "Deleted message ${message.id} ") - stopPlayingAudioMessage(message) - } - .onFailure { - Log.w("Loki", "FAILED TO delete message ${message.id} ") - showMessage("Couldn't delete message due to error: $it") - } - } - - fun deleteMessagesWithoutUnsendRequest(messages: Set) = viewModelScope.launch { - repository.deleteMessageWithoutUnsendRequest(threadId, messages) - .onFailure { - showMessage("Couldn't delete message due to error: $it") - } + fun setRecipientApproved() { + val recipient = recipient ?: return Log.w("Loki", "Recipient was null for set approved action") + repository.setApproved(recipient, true) } fun banUser(recipient: Recipient) = viewModelScope.launch { repository.banUser(threadId, recipient) .onSuccess { - showMessage("Successfully banned user") + showMessage(application.getString(R.string.banUserBanned)) } .onFailure { - showMessage("Couldn't ban user due to error: $it") + showMessage(application.getString(R.string.banErrorFailed)) } } @@ -326,13 +733,13 @@ class ConversationViewModel( repository.banAndDeleteAll(threadId, messageRecord.individualRecipient) .onSuccess { // At this point the server side messages have been successfully deleted.. - showMessage("Successfully banned user and deleted all their messages") + showMessage(application.getString(R.string.banUserBanned)) // ..so we can now delete all their messages in this thread from local storage & remove the views. repository.deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord) } .onFailure { - showMessage("Couldn't execute request due to error: $it") + showMessage(application.getString(R.string.banErrorFailed)) } } @@ -431,6 +838,40 @@ class ConversationViewModel( } } + fun onCommand(command: Commands) { + when (command) { + is Commands.ShowOpenUrlDialog -> { + _dialogsState.update { + it.copy(openLinkDialogUrl = command.url) + } + } + + is Commands.HideDeleteEveryoneDialog -> { + _dialogsState.update { + it.copy(deleteEveryone = null) + } + } + + is Commands.HideDeleteAllDevicesDialog -> { + _dialogsState.update { + it.copy(deleteAllDevices = null) + } + } + + is Commands.MarkAsDeletedLocally -> { + // hide dialog first + _dialogsState.update { + it.copy(deleteEveryone = null) + } + + deleteLocally(command.messages) + } + is Commands.MarkAsDeletedForEveryone -> { + markAsDeletedForEveryone(command.data) + } + } + } + @dagger.assisted.AssistedFactory interface AssistedFactory { fun create(threadId: Long, edKeyPair: KeyPair?): Factory @@ -440,6 +881,7 @@ class ConversationViewModel( class Factory @AssistedInject constructor( @Assisted private val threadId: Long, @Assisted private val edKeyPair: KeyPair?, + private val application: Application, private val repository: ConversationRepository, private val storage: StorageProtocol, private val messageDataProvider: MessageDataProvider, @@ -447,20 +889,48 @@ class ConversationViewModel( private val threadDb: ThreadDatabase, @ApplicationContext private val context: Context, + private val lokiMessageDb: LokiMessageDatabase, + private val textSecurePreferences: TextSecurePreferences ) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return ConversationViewModel( threadId = threadId, edKeyPair = edKeyPair, + application = application, repository = repository, storage = storage, messageDataProvider = messageDataProvider, groupDb = groupDb, threadDb = threadDb, + lokiMessageDb = lokiMessageDb, + textSecurePreferences = textSecurePreferences ) as T } } + + data class DialogsState( + val openLinkDialogUrl: String? = null, + val deleteEveryone: DeleteForEveryoneDialogData? = null, + val deleteAllDevices: DeleteForEveryoneDialogData? = null, + ) + + data class DeleteForEveryoneDialogData( + val messages: Set, + val messageType: MessageType, + val defaultToEveryone: Boolean, + val everyoneEnabled: Boolean, + val warning: String? = null + ) + + sealed class Commands { + data class ShowOpenUrlDialog(val url: String?) : Commands() + data object HideDeleteEveryoneDialog : Commands() + data object HideDeleteAllDevicesDialog : Commands() + + data class MarkAsDeletedLocally(val messages: Set): Commands() + data class MarkAsDeletedForEveryone(val data: DeleteForEveryoneDialogData): Commands() + } } data class UiMessage(val id: Long, val message: String) @@ -471,6 +941,7 @@ data class ConversationUiState( val shouldExit: Boolean = false, val showInput: Boolean = true, val enableInputMediaControls: Boolean = true, + val showLoader: Boolean = false ) sealed interface MessageRequestUiState { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt index d2ec4b2d69..c21de8021a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/input_bar/InputBarButton.kt @@ -77,7 +77,9 @@ class InputBarButton : RelativeLayout { result.layoutParams = LayoutParams(size, size) result.scaleType = ImageView.ScaleType.CENTER_INSIDE result.setImageResource(iconID) - result.imageTintList = ColorStateList.valueOf(context.getColorFromAttr(R.attr.input_bar_button_text_color)) + result.imageTintList = if(isSendButton) + ColorStateList.valueOf(context.getColorFromAttr(R.attr.message_sent_text_color)) + else ColorStateList.valueOf(context.getColorFromAttr(R.attr.input_bar_button_text_color)) result } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt index 52a32fb9ab..8697eeecff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt @@ -10,6 +10,7 @@ import org.session.libsession.messaging.utilities.SodiumUtilities import org.session.libsession.utilities.TextSecurePreferences import org.session.libsignal.utilities.IdPrefix import org.session.libsignal.utilities.AccountId +import org.session.libsignal.utilities.Log import org.thoughtcrime.securesms.conversation.v2.ConversationAdapter import org.thoughtcrime.securesms.database.model.MediaMmsMessageRecord import org.thoughtcrime.securesms.database.model.MessageRecord @@ -43,6 +44,9 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p // Embedded function fun userCanDeleteSelectedItems(): Boolean { + // admin can delete all combinations + if(adapter.isAdmin) return true + val allSentByCurrentUser = selectedItems.all { it.isOutgoing } val allReceivedByCurrentUser = selectedItems.all { !it.isOutgoing } if (openGroup == null) { return allSentByCurrentUser || allReceivedByCurrentUser } @@ -92,7 +96,7 @@ class ConversationActionModeCallback(private val adapter: ConversationAdapter, p } override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { - val selectedItems = adapter.selectedItems + val selectedItems = adapter.selectedItems.toSet() when (item.itemId) { R.id.menu_context_delete_message -> delegate?.deleteMessages(selectedItems) R.id.menu_context_ban_user -> delegate?.banUser(selectedItems) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt index 01d5b41f7c..190aa8b2dd 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/ControlMessageView.kt @@ -6,6 +6,7 @@ import android.content.Intent import android.util.AttributeSet import android.util.Log import android.view.LayoutInflater +import android.view.View import android.widget.LinearLayout import androidx.core.content.res.ResourcesCompat import androidx.core.view.isGone @@ -55,11 +56,13 @@ class ControlMessageView : LinearLayout { @Inject lateinit var disappearingMessages: DisappearingMessages + val controlContentView: View get() = binding.controlContentView + init { layoutParams = RecyclerView.LayoutParams(RecyclerView.LayoutParams.MATCH_PARENT, RecyclerView.LayoutParams.WRAP_CONTENT) } - fun bind(message: MessageRecord, previous: MessageRecord?) { + fun bind(message: MessageRecord, previous: MessageRecord?, longPress: (() -> Unit)? = null) { binding.dateBreakTextView.showDateBreak(message, previous) binding.iconImageView.isGone = true binding.expirationTimerView.isGone = true @@ -86,7 +89,7 @@ class ControlMessageView : LinearLayout { && message.expiryMode != (MessagingModuleConfiguration.shared.storage.getExpirationConfiguration(message.threadId)?.expiryMode ?: ExpiryMode.NONE) && threadRecipient?.isGroupRecipient != true - followSetting.setOnClickListener { disappearingMessages.showFollowSettingDialog(context, message) } + binding.controlContentView.setOnClickListener { disappearingMessages.showFollowSettingDialog(context, message) } } } message.isMediaSavedNotification -> { @@ -130,7 +133,7 @@ class ControlMessageView : LinearLayout { } // remove clicks by default - setOnClickListener(null) + binding.controlContentView.setOnClickListener(null) hideInfo() // handle click behaviour depending on criteria @@ -140,7 +143,7 @@ class ControlMessageView : LinearLayout { // show a dedicated privacy dialog !TextSecurePreferences.isCallNotificationsEnabled(context) -> { showInfo() - setOnClickListener { + binding.controlContentView.setOnClickListener { context.showSessionDialog { val titleTxt = context.getSubbedString( R.string.callsMissedCallFrom, @@ -167,7 +170,7 @@ class ControlMessageView : LinearLayout { // show a dedicated permission dialog !Permissions.hasAll(context, Manifest.permission.RECORD_AUDIO) -> { showInfo() - setOnClickListener { + binding.controlContentView.setOnClickListener { context.showSessionDialog { val titleTxt = context.getSubbedString( R.string.callsMissedCallFrom, @@ -207,6 +210,14 @@ class ControlMessageView : LinearLayout { binding.textView.isGone = message.isCallLog binding.callView.isVisible = message.isCallLog + + // handle long clicked if it was passed on + longPress?.let { + binding.controlContentView.setOnLongClickListener { + longPress.invoke() + true + } + } } fun showInfo(){ diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt index 5b64df059e..7a495a9478 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/DeletedMessageView.kt @@ -21,7 +21,8 @@ class DeletedMessageView : LinearLayout { // region Updating fun bind(message: MessageRecord, @ColorInt textColor: Int) { assert(message.isDeleted) - binding.deleteTitleTextView.text = context.resources.getQuantityString(R.plurals.deleteMessageDeleted, 1, 1) + // set the text to the message's body if it is set, else use a fallback + binding.deleteTitleTextView.text = message.body.ifEmpty { context.resources.getQuantityString(R.plurals.deleteMessageDeleted, 1, 1) } binding.deleteTitleTextView.setTextColor(textColor) binding.deletedMessageViewIconImageView.imageTintList = ColorStateList.valueOf(textColor) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt index 4c274f5ed1..a99e7b60d1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt @@ -80,6 +80,7 @@ class VisibleMessageContentView : ConstraintLayout { onContentDoubleTap = null if (message.isDeleted) { + binding.contentParent.isVisible = true binding.deletedMessageView.root.isVisible = true binding.deletedMessageView.root.bind(message, getTextColor(context, message)) binding.bodyTextView.isVisible = false diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt index 8c9e3a4fa5..35d193967e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageView.kt @@ -279,13 +279,12 @@ class VisibleMessageView : FrameLayout { // Get details regarding how we should display the message (it's delivery icon, icon tint colour, and // the resource string for what text to display (R.string.delivery_status_sent etc.). - val (iconID, iconColor, textId) = getMessageStatusInfo(message) - // If we get any nulls then a message isn't one with a state that we care about (i.e., control messages + // If we get a null messageStatus then the message isn't one with a state that we care about (i.e., control messages // etc.) - so bail. See: `DisplayRecord.is` for the full suite of message state methods. // Also: We set all delivery status elements visibility to false just to make sure we don't display any // stale data. - if (textId == null) return + val messageStatus = getMessageStatusInfo(message) ?: return binding.messageInnerLayout.modifyLayoutParams { gravity = if (message.isOutgoing) Gravity.END else Gravity.START @@ -294,16 +293,17 @@ class VisibleMessageView : FrameLayout { horizontalBias = if (message.isOutgoing) 1f else 0f } - // If the message is incoming AND it is not scheduled to disappear then don't show any status or timer details + // If the message is incoming AND it is not scheduled to disappear + // OR it is a deleted message then don't show any status or timer details val scheduledToDisappear = message.expiresIn > 0 - if (message.isIncoming && !scheduledToDisappear) return + if (message.isDeleted || message.isIncoming && !scheduledToDisappear) return // Set text & icons as appropriate for the message state. Note: Possible message states we care // about are: isFailed, isSyncFailed, isPending, isSyncing, isResyncing, isRead, and isSent. - textId.let(binding.messageStatusTextView::setText) - iconColor?.let(binding.messageStatusTextView::setTextColor) - iconID?.let { ContextCompat.getDrawable(context, it) } - ?.run { iconColor?.let { mutate().apply { setTint(it) } } ?: this } + messageStatus.messageText?.let(binding.messageStatusTextView::setText) + messageStatus.iconTint?.let(binding.messageStatusTextView::setTextColor) + messageStatus.iconId?.let { ContextCompat.getDrawable(context, it) } + ?.run { messageStatus.iconTint?.let { mutate().apply { setTint(it) } } ?: this } ?.let(binding.messageStatusImageView::setImageDrawable) // Potential options at this point are that the message is: @@ -380,7 +380,7 @@ class VisibleMessageView : FrameLayout { @ColorInt val iconTint: Int?, @StringRes val messageText: Int?) - private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo = when { + private fun getMessageStatusInfo(message: MessageRecord): MessageStatusInfo? = when { message.isFailed -> MessageStatusInfo(R.drawable.ic_delivery_status_failed, getThemedColor(context, R.attr.danger), @@ -409,7 +409,7 @@ class VisibleMessageView : FrameLayout { ) } } - message.isSyncing || message.isResyncing -> + message.isResyncing -> MessageStatusInfo( R.drawable.ic_delivery_status_sending, context.getColorFromAttr(R.attr.message_status_color), @@ -421,16 +421,21 @@ class VisibleMessageView : FrameLayout { context.getColorFromAttr(R.attr.message_status_color), R.string.read ) - message.isSent -> + message.isSyncing || message.isSent -> // syncing should happen silently in the bg so we can mark it as sent MessageStatusInfo( R.drawable.ic_delivery_status_sent, context.getColorFromAttr(R.attr.message_status_color), R.string.disappearingMessagesSent ) + + // deleted messages do not have a status but we care about styling them so they need to return something + message.isDeleted -> + MessageStatusInfo(null, null, null) + else -> { // The message isn't one we care about for message statuses we display to the user (i.e., // control messages etc. - see the `DisplayRecord.is` suite of methods for options). - MessageStatusInfo(null, null, null) + null } } @@ -479,10 +484,13 @@ class VisibleMessageView : FrameLayout { // region Interaction @SuppressLint("ClickableViewAccessibility") override fun onTouchEvent(event: MotionEvent): Boolean { - if (onPress == null || onSwipeToReply == null || onLongPress == null) { return false } + if (onPress == null && onSwipeToReply == null && onLongPress == null) { return false } when (event.action) { MotionEvent.ACTION_DOWN -> onDown(event) - MotionEvent.ACTION_MOVE -> onMove(event) + MotionEvent.ACTION_MOVE -> { + // only bother with movements if we have swipe to reply + onSwipeToReply?.let { onMove(event) } + } MotionEvent.ACTION_CANCEL -> onCancel(event) MotionEvent.ACTION_UP -> onUp(event) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java index baa78abad1..5622807127 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java @@ -45,7 +45,7 @@ public abstract class MessagingDatabase extends Database implements MmsSmsColumn public abstract void markUnidentified(long messageId, boolean unidentified); - public abstract void markAsDeleted(long messageId); + public abstract void markAsDeleted(long messageId, boolean isOutgoing, String displayedMessage); public abstract boolean deleteMessage(long messageId); public abstract boolean deleteMessages(long[] messageId, long threadId); diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt index fa32a9561e..03231151b7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt @@ -320,18 +320,21 @@ class MmsDatabase(context: Context, databaseHelper: SQLCipherOpenHelper) : Messa db.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) } - override fun markAsDeleted(messageId: Long) { + override fun markAsDeleted(messageId: Long, isOutgoing: Boolean, displayedMessage: String) { val database = databaseHelper.writableDatabase val contentValues = ContentValues() contentValues.put(READ, 1) - contentValues.put(BODY, "") + contentValues.put(BODY, displayedMessage) contentValues.put(HAS_MENTION, 0) database.update(TABLE_NAME, contentValues, ID_WHERE, arrayOf(messageId.toString())) val attachmentDatabase = get(context).attachmentDatabase() queue { attachmentDatabase.deleteAttachmentsForMessage(messageId) } val threadId = getThreadIdForMessage(messageId) - markAs(messageId, MmsSmsColumns.Types.BASE_DELETED_TYPE, threadId) + val deletedType = if (isOutgoing) { MmsSmsColumns.Types.BASE_DELETED_OUTGOING_TYPE} else { + MmsSmsColumns.Types.BASE_DELETED_INCOMING_TYPE + } + markAs(messageId, deletedType, threadId) } override fun markExpireStarted(messageId: Long, startedTimestamp: Long) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java index de5094fbd6..a5db6ca4d3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsSmsColumns.java @@ -42,6 +42,7 @@ public interface MmsSmsColumns { protected static final long JOINED_TYPE = 4; protected static final long FIRST_MISSED_CALL_TYPE = 5; + protected static final long BASE_DELETED_INCOMING_TYPE = 19; protected static final long BASE_INBOX_TYPE = 20; protected static final long BASE_OUTBOX_TYPE = 21; protected static final long BASE_SENDING_TYPE = 22; @@ -50,7 +51,7 @@ public interface MmsSmsColumns { protected static final long BASE_PENDING_SECURE_SMS_FALLBACK = 25; protected static final long BASE_PENDING_INSECURE_SMS_FALLBACK = 26; public static final long BASE_DRAFT_TYPE = 27; - protected static final long BASE_DELETED_TYPE = 28; + protected static final long BASE_DELETED_OUTGOING_TYPE = 28; protected static final long BASE_SYNCING_TYPE = 29; protected static final long BASE_RESYNCING_TYPE = 30; protected static final long BASE_SYNC_FAILED_TYPE = 31; @@ -61,6 +62,7 @@ public interface MmsSmsColumns { BASE_SENDING_TYPE, BASE_SENT_FAILED_TYPE, BASE_PENDING_SECURE_SMS_FALLBACK, BASE_PENDING_INSECURE_SMS_FALLBACK, + BASE_DELETED_OUTGOING_TYPE, OUTGOING_CALL_TYPE}; @@ -182,7 +184,9 @@ public interface MmsSmsColumns { return (type & BASE_TYPE_MASK) == BASE_INBOX_TYPE; } - public static boolean isDeletedMessage(long type) { return (type & BASE_TYPE_MASK) == BASE_DELETED_TYPE; } + public static boolean isDeletedMessage(long type) { + return (type & BASE_TYPE_MASK) == BASE_DELETED_OUTGOING_TYPE || (type & BASE_TYPE_MASK) == BASE_DELETED_INCOMING_TYPE; + } public static boolean isJoinedType(long type) { return (type & BASE_TYPE_MASK) == JOINED_TYPE; diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt index 87c0b6c182..150ec073e1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/ReactionDatabase.kt @@ -159,7 +159,7 @@ class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database ) } - private fun deleteReactions(messageId: MessageId, query: String, args: Array, notifyUnread: Boolean) { + private fun deleteReactions(messageId: MessageId, query: String, args: Array, notifyUnread: Boolean) { writableDatabase.beginTransaction() try { writableDatabase.delete(TABLE_NAME, query, args) @@ -174,7 +174,54 @@ class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database } finally { writableDatabase.endTransaction() } - } + } + + fun deleteMessageReactions(messageIds: List) { + if (messageIds.isEmpty()) return // Early exit if the list is empty + + val conditions = mutableListOf() + val args = mutableListOf() + + for (messageId in messageIds) { + conditions.add("($MESSAGE_ID = ? AND $IS_MMS = ?)") + args.add(messageId.id.toString()) + args.add(if (messageId.mms) "1" else "0") + } + + val query = conditions.joinToString(" OR ") + + deleteReactions( + messageIds = messageIds, + query = query, + args = args.toTypedArray(), + notifyUnread = false + ) + } + + private fun deleteReactions(messageIds: List, query: String, args: Array, notifyUnread: Boolean) { + writableDatabase.beginTransaction() + try { + writableDatabase.delete(TABLE_NAME, query, args) + + // Update unread status for each message + for (messageId in messageIds) { + val hasReaction = hasReactions(messageId) + if (messageId.mms) { + DatabaseComponent.get(context).mmsDatabase().updateReactionsUnread( + writableDatabase, messageId.id, hasReaction, true, notifyUnread + ) + } else { + DatabaseComponent.get(context).smsDatabase().updateReactionsUnread( + writableDatabase, messageId.id, hasReaction, true, notifyUnread + ) + } + } + + writableDatabase.setTransactionSuccessful() + } finally { + writableDatabase.endTransaction() + } + } private fun hasReactions(messageId: MessageId): Boolean { val query = "$MESSAGE_ID = ? AND $IS_MMS = ?" diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java index adf79fb2ed..728ee1e3f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java +++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java @@ -237,14 +237,17 @@ public class SmsDatabase extends MessagingDatabase { } @Override - public void markAsDeleted(long messageId) { + public void markAsDeleted(long messageId, boolean isOutgoing, String displayedMessage) { SQLiteDatabase database = databaseHelper.getWritableDatabase(); ContentValues contentValues = new ContentValues(); contentValues.put(READ, 1); - contentValues.put(BODY, ""); + contentValues.put(BODY, displayedMessage); contentValues.put(HAS_MENTION, 0); database.update(TABLE_NAME, contentValues, ID_WHERE, new String[] {String.valueOf(messageId)}); - updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, Types.BASE_DELETED_TYPE); + + updateTypeBitmask(messageId, Types.BASE_TYPE_MASK, + isOutgoing? MmsSmsColumns.Types.BASE_DELETED_OUTGOING_TYPE : MmsSmsColumns.Types.BASE_DELETED_INCOMING_TYPE + ); } @Override diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt index 0c97145373..05f0ac0558 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt @@ -65,6 +65,9 @@ import org.session.libsession.utilities.TextSecurePreferences import org.session.libsession.utilities.getClosedGroup import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient.DisappearingState +import org.session.libsession.utilities.recipients.MessageType +import org.session.libsession.utilities.recipients.getType +import org.session.libsignal.crypto.ecc.DjbECPrivateKey import org.session.libsignal.crypto.ecc.DjbECPublicKey import org.session.libsignal.crypto.ecc.ECKeyPair import org.session.libsignal.messages.SignalServiceAttachmentPointer @@ -658,6 +661,12 @@ open class Storage @Inject constructor( return database.getMessageFor(timestamp, address)?.run { getId() to isMms } } + override fun getMessageType(timestamp: Long, author: String): MessageType? { + val database = DatabaseComponent.get(context).mmsSmsDatabase() + val address = fromSerialized(author) + return database.getMessageFor(timestamp, address)?.individualRecipient?.getType() + } + override fun updateSentTimestamp( messageID: Long, isMms: Boolean, @@ -1807,6 +1816,12 @@ open class Storage @Inject constructor( reactionDatabase.deleteMessageReactions(MessageId(messageId, mms)) } + override fun deleteReactions(messageIds: List, mms: Boolean) { + DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions( + messageIds.map { MessageId(it, mms) } + ) + } + override fun setBlocked(recipients: Iterable, isBlocked: Boolean, fromConfigUpdate: Boolean) { val recipientDb = recipientDatabase recipientDb.setBlocked(recipients, isBlocked) diff --git a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt index 89e8b282b3..4226c61289 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt @@ -543,7 +543,6 @@ class HomeActivity : PassphraseRequiredActionBarActivity(), } else { showMuteDialog(this) { until -> lifecycleScope.launch(Dispatchers.Default) { - Log.d("", "**** until: $until") recipientDatabase.setMuted(thread.recipient, until) withContext(Dispatchers.Main) { binding.recyclerView.adapter!!.notifyDataSetChanged() diff --git a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt index acb41e3383..3a24b0375c 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt @@ -1,7 +1,9 @@ package org.thoughtcrime.securesms.notifications +import android.Manifest import android.content.Context import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat.getString @@ -38,39 +40,52 @@ class PushReceiver @Inject constructor( ) { private val json = Json { ignoreUnknownKeys = true } - fun onPush(dataMap: Map?) { - val result = dataMap?.decodeAndDecrypt() - val data = result?.first - if (data == null) { - onPush() + /** + * Both push services should hit this method once they receive notification data + * As long as it is properly formatted + */ + fun onPushDataReceived(dataMap: Map?) { + addMessageReceiveJob(dataMap?.asPushData()) + } + + /** + * This is a fallback method in case the Huawei data is malformated, + * but it shouldn't happen. Old code used to send different data so this is kept as a safety + */ + fun onPushDataReceived(data: ByteArray?) { + addMessageReceiveJob(PushData(data = data, metadata = null)) + } + + private fun addMessageReceiveJob(pushData: PushData?){ + // send a generic notification if we have no data + if (pushData?.data == null) { + sendGenericNotification() return } - handlePushData(data = data, metadata = result.second) - } - - private fun handlePushData(data: ByteArray, metadata: PushNotificationMetadata?) { try { val params = when { - metadata?.namespace == Namespace.CLOSED_GROUP_MESSAGES() -> { - val groupId = AccountId(requireNotNull(metadata.account) { + pushData.metadata?.namespace == Namespace.CLOSED_GROUP_MESSAGES() -> { + val groupId = AccountId(requireNotNull(pushData.metadata.account) { "Received a closed group message push notification without an account ID" }) - val envelop = checkNotNull(tryDecryptGroupMessage(groupId, data)) { + val envelop = checkNotNull(tryDecryptGroupMessage(groupId, pushData.data)) { "Unable to decrypt closed group message" } MessageReceiveParameters( data = envelop.toByteArray(), - serverHash = metadata.msg_hash, + serverHash = pushData.metadata.msg_hash, closedGroup = Destination.ClosedGroup(groupId.hexString) ) } - metadata?.namespace == 0 || metadata == null -> { + pushData.metadata?.namespace == 0 || pushData.metadata == null -> { + val envelopeAsData = MessageWrapper.unwrap(pushData.data).toByteArray() MessageReceiveParameters( - data = MessageWrapper.unwrap(data).toByteArray(), + data = envelopeAsData, + serverHash = pushData.metadata?.msg_hash ) } @@ -84,7 +99,9 @@ class PushReceiver @Inject constructor( } catch (e: Exception) { Log.d(TAG, "Failed to unwrap data for message due to error.", e) } + } + private fun tryDecryptGroupMessage(groupId: AccountId, data: ByteArray): Envelope? { val (envelopBytes, sender) = checkNotNull(configFactory.withGroupConfigs(groupId) { it.groupKeys.decrypt(data) }) { @@ -98,8 +115,18 @@ class PushReceiver @Inject constructor( .build() } - private fun onPush() { + private fun sendGenericNotification() { Log.d(TAG, "Failed to decode data for message.") + + // no need to do anything if notification permissions are not granted + if (ActivityCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + return + } + val builder = NotificationCompat.Builder(context, NotificationChannels.OTHER) .setSmallIcon(R.drawable.ic_notification) .setColor(context.getColor(R.color.textsecure_primary)) @@ -111,12 +138,10 @@ class PushReceiver @Inject constructor( .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) - if (context.checkSelfPermission(android.Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - NotificationManagerCompat.from(context).notify(11111, builder.build()) - } + NotificationManagerCompat.from(context).notify(11111, builder.build()) } - private fun Map.decodeAndDecrypt() = + private fun Map.asPushData(): PushData = when { // this is a v2 push notification containsKey("spns") -> { @@ -124,14 +149,14 @@ class PushReceiver @Inject constructor( decrypt(Base64.decode(this["enc_payload"])) } catch (e: Exception) { Log.e(TAG, "Invalid push notification", e) - null + PushData(null, null) } } // old v1 push notification; we still need this for receiving legacy closed group notifications - else -> this["ENCRYPTED_DATA"]?.let { Base64.decode(it) to null } + else -> PushData(this["ENCRYPTED_DATA"]?.let(Base64::decode), null) } - private fun decrypt(encPayload: ByteArray): Pair { + private fun decrypt(encPayload: ByteArray): PushData { Log.d(TAG, "decrypt() called") val encKey = getOrCreateNotificationKey() @@ -149,11 +174,14 @@ class PushReceiver @Inject constructor( val metadataJson = (expectedList[0] as? BencodeString)?.value ?: error("no metadata") val metadata: PushNotificationMetadata = json.decodeFromString(String(metadataJson)) - return (expectedList.getOrNull(1) as? BencodeString)?.value.also { - // null content is valid only if we got a "data_too_long" flag - it?.let { check(metadata.data_len == it.size) { "wrong message data size" } } + return PushData( + data = (expectedList.getOrNull(1) as? BencodeString)?.value, + metadata = metadata + ).also { pushData -> + // null data content is valid only if we got a "data_too_long" flag + pushData.data?.let { check(metadata.data_len == it.size) { "wrong message data size" } } ?: check(metadata.data_too_long) { "missing message data, but no too-long flag" } - } to metadata + } } fun getOrCreateNotificationKey(): Key { @@ -167,4 +195,9 @@ class PushReceiver @Inject constructor( IdentityKeyUtil.save(context, IdentityKeyUtil.NOTIFICATION_KEY, key.asHexString) return key } + + data class PushData( + val data: ByteArray?, + val metadata: PushNotificationMetadata? + ) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt index 29ce563c5d..89225d2562 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/preferences/ShareLogsDialog.kt @@ -115,7 +115,7 @@ class ShareLogsDialog(private val updateCallback: (Boolean)->Unit): DialogFragme } catch (e: Exception) { withContext(Main) { Log.e("Loki", "Error saving logs", e) - Toast.makeText(context,"Error saving logs", Toast.LENGTH_LONG).show() + Toast.makeText(context,getString(R.string.errorUnknown), Toast.LENGTH_LONG).show() } } }.also { shareJob -> diff --git a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt index 46dbc7d811..92e46e2184 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt @@ -14,6 +14,7 @@ import org.session.libsession.database.MessageDataProvider import org.session.libsession.database.userAuth import org.session.libsession.messaging.groups.GroupManagerV2 import org.session.libsession.messaging.messages.Destination +import org.session.libsession.messaging.messages.MarkAsDeletedMessage import org.session.libsession.messaging.messages.control.MessageRequestResponse import org.session.libsession.messaging.messages.control.UnsendRequest import org.session.libsession.messaging.messages.signal.OutgoingTextMessage @@ -45,6 +46,8 @@ import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.ThreadRecord import org.thoughtcrime.securesms.dependencies.ConfigFactory import javax.inject.Inject +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine interface ConversationRepository { fun maybeGetRecipientForThreadId(threadId: Long): Recipient? @@ -56,13 +59,29 @@ interface ConversationRepository { fun clearDrafts(threadId: Long) fun inviteContacts(threadId: Long, contacts: List) fun setBlocked(threadId: Long, recipient: Recipient, blocked: Boolean) - fun deleteLocally(recipient: Recipient, message: MessageRecord) + fun markAsDeletedLocally(messages: Set, displayedMessage: String) + fun deleteMessages(messages: Set, threadId: Long) fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) fun setApproved(recipient: Recipient, isApproved: Boolean) fun isKicked(recipient: Recipient): Boolean - suspend fun deleteForEveryone(threadId: Long, recipient: Recipient, message: MessageRecord): Result - suspend fun deleteMessageWithoutUnsendRequest(threadId: Long, messages: Set): Result + suspend fun deleteCommunityMessagesRemotely(threadId: Long, messages: Set) + suspend fun delete1on1MessagesRemotely( + threadId: Long, + recipient: Recipient, + messages: Set + ) + suspend fun deleteNoteToSelfMessagesRemotely( + threadId: Long, + recipient: Recipient, + messages: Set + ) + suspend fun deleteLegacyGroupMessagesRemotely( + recipient: Recipient, + messages: Set + ) + + fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest? suspend fun banUser(threadId: Long, recipient: Recipient): Result suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): Result suspend fun deleteThread(threadId: Long): Result @@ -174,14 +193,57 @@ class DefaultConversationRepository @Inject constructor( } } - override fun deleteLocally(recipient: Recipient, message: MessageRecord) { - if (shouldSendUnsendRequest(recipient)) { - textSecurePreferences.getLocalNumber()?.let { - MessageSender.send(buildUnsendRequest(message), Address.fromSerialized(it)) - } + /** + * This will delete these messages from the db + * Not to be confused with 'marking messages as deleted' + */ + override fun deleteMessages(messages: Set, threadId: Long) { + // split the messages into mms and sms + val (mms, sms) = messages.partition { it.isMms } + + if(mms.isNotEmpty()){ + messageDataProvider.deleteMessages(mms.map { it.id }, threadId, isSms = false) } - messageDataProvider.deleteMessage(message.id, !message.isMms) + if(sms.isNotEmpty()){ + messageDataProvider.deleteMessages(sms.map { it.id }, threadId, isSms = true) + } + } + + /** + * This will mark the messages as deleted. + * They won't be removed from the db but instead will appear as a special type + * of message that says something like "This message was deleted" + */ + override fun markAsDeletedLocally(messages: Set, displayedMessage: String) { + // split the messages into mms and sms + val (mms, sms) = messages.partition { it.isMms } + + if(mms.isNotEmpty()){ + messageDataProvider.markMessagesAsDeleted(mms.map { MarkAsDeletedMessage( + messageId = it.id, + isOutgoing = it.isOutgoing + ) }, + isSms = false, + displayedMessage = displayedMessage + ) + + // delete reactions + storage.deleteReactions(messageIds = mms.map { it.id }, mms = true) + } + + if(sms.isNotEmpty()){ + messageDataProvider.markMessagesAsDeleted(sms.map { MarkAsDeletedMessage( + messageId = it.id, + isOutgoing = it.isOutgoing + ) }, + isSms = true, + displayedMessage = displayedMessage + ) + + // delete reactions + storage.deleteReactions(messageIds = sms.map { it.id }, mms = false) + } } override fun deleteAllLocalMessagesInThreadFromSenderOfMessage(messageRecord: MessageRecord) { @@ -197,74 +259,82 @@ class DefaultConversationRepository @Inject constructor( storage.setRecipientApproved(recipient, isApproved) } - override suspend fun deleteForEveryone( + override suspend fun deleteCommunityMessagesRemotely( + threadId: Long, + messages: Set + ) { + val community = checkNotNull(lokiThreadDb.getOpenGroupChat(threadId)) { "Not a community" } + + messages.forEach { message -> + lokiMessageDb.getServerID(message.id, !message.isMms)?.let { messageServerID -> + OpenGroupApi.deleteMessage(messageServerID, community.room, community.server).await() + } + } + } + + override suspend fun delete1on1MessagesRemotely( threadId: Long, recipient: Recipient, - message: MessageRecord - ): Result { - return runCatching { - withContext(Dispatchers.Default) { - val openGroup = lokiThreadDb.getOpenGroupChat(threadId) - if (openGroup != null) { - val serverId = lokiMessageDb.getServerID(message.id, !message.isMms) - if (serverId != null) { - OpenGroupApi.deleteMessage( - serverID = serverId, - room = openGroup.room, - server = openGroup.server - ).await() - messageDataProvider.deleteMessage(message.id, !message.isMms) - } else { - // If the server ID is null then this message is stuck in limbo (it has likely been - // deleted remotely but that deletion did not occur locally) - so we'll delete the - // message locally to clean up. - Log.w( - "ConversationRepository", - "Found community message without a server ID - deleting locally." - ) + messages: Set + ) { + // delete the messages remotely + val publicKey = recipient.address.serialize() + val userAddress: Address? = textSecurePreferences.getLocalNumber()?.let { Address.fromSerialized(it) } - // Caution: The bool returned from `deleteMessage` is NOT "Was the message - // successfully deleted?" - it is "Was the thread itself also deleted because - // removing that message resulted in an empty thread?". - if (message.isMms) { - mmsDb.deleteMessage(message.id) - } else { - smsDb.deleteMessage(message.id) - } - } - } else // If this thread is NOT in a Community - { - val serverHash = - messageDataProvider.getServerHashForMessage(message.id, message.isMms) - if (serverHash != null) { - var publicKey = recipient.address.serialize() - if (recipient.isLegacyClosedGroupRecipient) { - publicKey = GroupUtil.doubleDecodeGroupID(publicKey).toHexString() - } - - if (recipient.isClosedGroupV2Recipient) { - // admin check internally, assume either admin or all belong to user - groupManager.requestMessageDeletion( - groupId = AccountId(publicKey), - messageHashes = listOf(serverHash) - ) - } else { - SnodeAPI.deleteMessage( - publicKey = publicKey, - swarmAuth = storage.userAuth!!, - serverHashes = listOf(serverHash) - ).await() - } - - if (shouldSendUnsendRequest(recipient)) { - MessageSender.send( - message = buildUnsendRequest(message), - address = recipient.address, - ) - } - } - messageDataProvider.deleteMessage(message.id, !message.isMms) + messages.forEach { message -> + // delete from swarm + messageDataProvider.getServerHashForMessage(message.id, message.isMms) + ?.let { serverHash -> + SnodeAPI.deleteMessage(publicKey, listOf(serverHash)) } + + // send an UnsendRequest to user's swarm + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + userAddress?.let { MessageSender.send(unsendRequest, it) } + } + + // send an UnsendRequest to recipient's swarm + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + MessageSender.send(unsendRequest, recipient.address) + } + } + } + + override suspend fun deleteLegacyGroupMessagesRemotely( + recipient: Recipient, + messages: Set + ) { + if (recipient.isClosedGroupRecipient) { + val publicKey = recipient.address + + messages.forEach { message -> + // send an UnsendRequest to group's swarm + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + MessageSender.send(unsendRequest, publicKey) + } + } + } + } + + override suspend fun deleteNoteToSelfMessagesRemotely( + threadId: Long, + recipient: Recipient, + messages: Set + ) { + // delete the messages remotely + val publicKey = recipient.address.serialize() + val userAddress: Address? = textSecurePreferences.getLocalNumber()?.let { Address.fromSerialized(it) } + + messages.forEach { message -> + // delete from swarm + messageDataProvider.getServerHashForMessage(message.id, message.isMms) + ?.let { serverHash -> + SnodeAPI.deleteMessage(publicKey, listOf(serverHash)) + } + + // send an UnsendRequest to user's swarm + buildUnsendRequest(recipient, message)?.let { unsendRequest -> + userAddress?.let { MessageSender.send(unsendRequest, it) } } } } @@ -280,35 +350,6 @@ class DefaultConversationRepository @Inject constructor( ) } - override suspend fun deleteMessageWithoutUnsendRequest( - threadId: Long, - messages: Set - ): Result = kotlin.runCatching { - withContext(Dispatchers.Default) { - val openGroup = lokiThreadDb.getOpenGroupChat(threadId) - if (openGroup != null) { - val messageServerIDs = mutableMapOf() - for (message in messages) { - val messageServerID = - lokiMessageDb.getServerID(message.id, !message.isMms) ?: continue - messageServerIDs[messageServerID] = message - } - messageServerIDs.forEach { (messageServerID, message) -> - OpenGroupApi.deleteMessage(messageServerID, openGroup.room, openGroup.server).await() - messageDataProvider.deleteMessage(message.id, !message.isMms) - } - } else { - for (message in messages) { - if (message.isMms) { - mmsDb.deleteMessage(message.id) - } else { - smsDb.deleteMessage(message.id) - } - } - } - } - } - override suspend fun banUser(threadId: Long, recipient: Recipient): Result = runCatching { val accountID = recipient.address.toString() val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!! diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt index cabe536767..d272a0d4f7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/components/RadioButton.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.conversation.disappearingmessages.ExpiryType @@ -116,16 +117,20 @@ private fun RadioButtonIndicator( @Composable fun TitledRadioButton( modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues( + horizontal = LocalDimensions.current.spacing, + vertical = LocalDimensions.current.smallSpacing + ), option: RadioOption, onClick: () -> Unit ) { RadioButton( - modifier = modifier.heightIn(min = 60.dp) + modifier = modifier .contentDescription(option.contentDescription), onClick = onClick, selected = option.selected, enabled = option.enabled, - contentPadding = PaddingValues(horizontal = LocalDimensions.current.spacing), + contentPadding = contentPadding, content = { Column( modifier = Modifier diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt index 051344ae6a..b5b6aa6c8b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt @@ -21,6 +21,7 @@ interface ThemeColors { val warning: Color val textAlert: Color val danger: Color + val warning: Color val disabled: Color val background: Color val backgroundSecondary: Color @@ -102,7 +103,7 @@ fun dangerButtonColors() = ButtonDefaults.buttonColors( data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors { override val isLight = false override val danger = dangerDark - override val warning = warningUniversal + override val warning = primaryOrange override val disabled = disabledDark override val background = classicDark0 override val backgroundSecondary = classicDark1 @@ -122,7 +123,7 @@ data class ClassicDark(override val primary: Color = primaryGreen) : ThemeColors data class ClassicLight(override val primary: Color = primaryGreen) : ThemeColors { override val isLight = true override val danger = dangerLight - override val warning = warningUniversal + override val warning = primaryOrange override val disabled = disabledLight override val background = classicLight6 override val backgroundSecondary = classicLight5 @@ -142,7 +143,7 @@ data class ClassicLight(override val primary: Color = primaryGreen) : ThemeColor data class OceanDark(override val primary: Color = primaryBlue) : ThemeColors { override val isLight = false override val danger = dangerDark - override val warning = warningUniversal + override val warning = primaryOrange override val disabled = disabledDark override val background = oceanDark2 override val backgroundSecondary = oceanDark1 @@ -162,7 +163,7 @@ data class OceanDark(override val primary: Color = primaryBlue) : ThemeColors { data class OceanLight(override val primary: Color = primaryBlue) : ThemeColors { override val isLight = true override val danger = dangerLight - override val warning = warningUniversal + override val warning = primaryOrange override val disabled = disabledLight override val background = oceanLight7 override val backgroundSecondary = oceanLight6 diff --git a/app/src/main/res/drawable/ic_arrow_up_circle_24.xml b/app/src/main/res/drawable/ic_arrow_up_circle_24.xml deleted file mode 100644 index fc53bc0971..0000000000 --- a/app/src/main/res/drawable/ic_arrow_up_circle_24.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_copy.xml b/app/src/main/res/drawable/ic_copy.xml index d1d99d4327..4c6eec2c14 100644 --- a/app/src/main/res/drawable/ic_copy.xml +++ b/app/src/main/res/drawable/ic_copy.xml @@ -2,11 +2,12 @@ android:width="24dp" android:height="24dp" android:viewportWidth="50" - android:viewportHeight="50"> + android:viewportHeight="50" + android:tint="?attr/colorControlNormal"> + android:fillColor="#FFFFFF"/> + android:fillColor="#FFFFFF"/> diff --git a/app/src/main/res/drawable/ic_radio_selected.xml b/app/src/main/res/drawable/ic_radio_selected.xml index 82021810e9..4739fdcad6 100644 --- a/app/src/main/res/drawable/ic_radio_selected.xml +++ b/app/src/main/res/drawable/ic_radio_selected.xml @@ -10,4 +10,5 @@ android:strokeWidth="1" android:pathData="M13.567,13.713m-12.5,0a12.5,12.5 0,1 1,25 0a12.5,12.5 0,1 1,-25 0" android:strokeColor="?textColorAlert"/> - \ No newline at end of file + + diff --git a/app/src/main/res/drawable/ic_radio_unselected.xml b/app/src/main/res/drawable/ic_radio_unselected.xml index 285dbb276e..a1ff8671ac 100644 --- a/app/src/main/res/drawable/ic_radio_unselected.xml +++ b/app/src/main/res/drawable/ic_radio_unselected.xml @@ -8,4 +8,5 @@ android:pathData="M13.567,13.713m-12.5,0a12.5,12.5 0,1 1,25 0a12.5,12.5 0,1 1,-25 0" android:fillColor="#00000000" android:strokeColor="?textColorAlert"/> - \ No newline at end of file + + diff --git a/app/src/main/res/layout/activity_conversation_v2.xml b/app/src/main/res/layout/activity_conversation_v2.xml index 8b4fb1eefe..c0847f3dad 100644 --- a/app/src/main/res/layout/activity_conversation_v2.xml +++ b/app/src/main/res/layout/activity_conversation_v2.xml @@ -386,4 +386,23 @@ android:layout_width="match_parent" android:layout_height="match_parent" /> + + + + + + diff --git a/app/src/main/res/layout/activity_home.xml b/app/src/main/res/layout/activity_home.xml index b47c67e5c9..8a2bc7180a 100644 --- a/app/src/main/res/layout/activity_home.xml +++ b/app/src/main/res/layout/activity_home.xml @@ -173,7 +173,8 @@ android:layout_centerHorizontal="true" android:layout_marginBottom="@dimen/new_conversation_button_bottom_offset" app:rippleColor="@color/button_primary_ripple" - android:src="@drawable/ic_plus" /> + android:src="@drawable/ic_plus" + android:tint="?message_sent_text_color"/> diff --git a/app/src/main/res/layout/mediasend_fragment.xml b/app/src/main/res/layout/mediasend_fragment.xml index 36e1e854f3..7224cabd19 100644 --- a/app/src/main/res/layout/mediasend_fragment.xml +++ b/app/src/main/res/layout/mediasend_fragment.xml @@ -118,10 +118,11 @@ android:id="@+id/mediasend_send_button" android:layout_width="match_parent" android:layout_height="match_parent" - android:scaleType="fitXY" + android:scaleType="fitCenter" + android:padding="10dp" android:contentDescription="@string/send" - android:src="?conversation_transport_sms_indicator" - android:background="@drawable/circle_touch_highlight_background"/> + android:src="@drawable/ic_arrow_up" + android:background="@drawable/accent_dot"/> diff --git a/app/src/main/res/layout/view_control_message.xml b/app/src/main/res/layout/view_control_message.xml index 660f732c51..0ca39ca387 100644 --- a/app/src/main/res/layout/view_control_message.xml +++ b/app/src/main/res/layout/view_control_message.xml @@ -27,7 +27,7 @@ android:visibility="gone" app:tint="?android:textColorTertiary" tools:src="@drawable/ic_timer" - tools:visibility="visible"/> + tools:visibility="visible" /> + tools:visibility="visible" /> - - - + android:orientation="vertical"> + android:layout_height="wrap_content" + android:contentDescription="@string/AccessibilityId_control_message" + android:gravity="center" + android:textColor="?android:textColorTertiary" + android:textSize="@dimen/very_small_font_size" + tools:text="You disabled disappearing messages" /> - + - + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/view_deleted_message.xml b/app/src/main/res/layout/view_deleted_message.xml index ceb391a342..719783a0d9 100644 --- a/app/src/main/res/layout/view_deleted_message.xml +++ b/app/src/main/res/layout/view_deleted_message.xml @@ -2,12 +2,11 @@ + android:padding="@dimen/small_spacing"> - - diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 3a0a950a6d..9793d738d2 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -164,7 +164,7 @@ @drawable/unimportant_dialog_text_button_background bold - +