Merge remote-tracking branch 'origin/dev' into closed_groups

# Conflicts:
#	app/src/main/java/org/thoughtcrime/securesms/attachments/DatabaseAttachmentProvider.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationActivityV2.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/menus/ConversationActionModeCallback.kt
#	app/src/main/java/org/thoughtcrime/securesms/conversation/v2/messages/VisibleMessageContentView.kt
#	app/src/main/java/org/thoughtcrime/securesms/database/MessagingDatabase.java
#	app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.kt
#	app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
#	app/src/main/java/org/thoughtcrime/securesms/database/Storage.kt
#	app/src/main/java/org/thoughtcrime/securesms/home/HomeActivity.kt
#	app/src/main/java/org/thoughtcrime/securesms/notifications/PushReceiver.kt
#	app/src/main/java/org/thoughtcrime/securesms/repository/ConversationRepository.kt
#	app/src/main/java/org/thoughtcrime/securesms/ui/theme/ThemeColors.kt
#	app/src/main/res/layout/view_control_message.xml
#	app/src/test/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModelTest.kt
#	libsession/src/main/java/org/session/libsession/database/MessageDataProvider.kt
#	libsession/src/main/java/org/session/libsession/messaging/sending_receiving/ReceivedMessageHandler.kt
#	libsession/src/main/java/org/session/libsession/snode/SnodeAPI.kt
#	libsignal/src/main/java/org/session/libsignal/utilities/Retrying.kt
This commit is contained in:
SessionHero01
2024-10-15 14:17:54 +11:00
136 changed files with 1453 additions and 774 deletions

View File

@@ -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"

View File

@@ -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?) {

View File

@@ -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)
}
}
}

View File

@@ -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;

View File

@@ -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<Long>, 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<MarkAsDeletedMessage>,
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? =

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -4,6 +4,7 @@ import android.content.Context
import androidx.annotation.AttrRes
import androidx.annotation.ColorInt
/**
* Represents an action to be rendered
*/

View File

@@ -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<MessageRecord>) {
handleLongPress(messages.first(), 0) //TODO: begin selection mode
}
// The option to "Delete just for me" or "Delete for everyone"
private fun showDeleteOrDeleteForEveryoneInCommunityUI(messages: Set<MessageRecord>) {
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<MessageRecord>) {
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<MessageRecord>) {
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<MessageRecord>) {

View File

@@ -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<MessageRecord>()
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) }
)
}
}
}

View File

@@ -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
}

View File

@@ -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 = {}
)
}
}

View File

@@ -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<ConversationUiState> get() = _uiState
private val _dialogsState = MutableStateFlow(DialogsState())
val dialogsState: StateFlow<DialogsState> = _dialogsState
private val _isAdmin = MutableStateFlow(false)
val isAdmin: StateFlow<Boolean> = _isAdmin
private var _recipient: RetrieveOnce<Recipient> = 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<MessageRecord>){
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<MessageRecord>) {
// make sure to stop audio messages, if any
messages.filterIsInstance<MmsMessageRecord>()
.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<MmsMessageRecord>()
.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<MessageRecord>){
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<MessageRecord>) = 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 <T : ViewModel> create(modelClass: Class<T>): 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<MessageRecord>,
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<MessageRecord>): 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 {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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(){

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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<WHATEVER>` 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<FrameLayout.LayoutParams> {
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<WHATEVER>` 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)
}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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;

View File

@@ -159,7 +159,7 @@ class ReactionDatabase(context: Context, helper: SQLCipherOpenHelper) : Database
)
}
private fun deleteReactions(messageId: MessageId, query: String, args: Array<String>, notifyUnread: Boolean) {
private fun deleteReactions(messageId: MessageId, query: String, args: Array<String>, 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<MessageId>) {
if (messageIds.isEmpty()) return // Early exit if the list is empty
val conditions = mutableListOf<String>()
val args = mutableListOf<String>()
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<MessageId>, query: String, args: Array<String>, 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 = ?"

View File

@@ -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

View File

@@ -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<Long>, mms: Boolean) {
DatabaseComponent.get(context).reactionDatabase().deleteMessageReactions(
messageIds.map { MessageId(it, mms) }
)
}
override fun setBlocked(recipients: Iterable<Recipient>, isBlocked: Boolean, fromConfigUpdate: Boolean) {
val recipientDb = recipientDatabase
recipientDb.setBlocked(recipients, isBlocked)

View File

@@ -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()

View File

@@ -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<String, String>?) {
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<String, String>?) {
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<String, String>.decodeAndDecrypt() =
private fun Map<String, String>.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<ByteArray?, PushNotificationMetadata?> {
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?
)
}

View File

@@ -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 ->

View File

@@ -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<Recipient>)
fun setBlocked(threadId: Long, recipient: Recipient, blocked: Boolean)
fun deleteLocally(recipient: Recipient, message: MessageRecord)
fun markAsDeletedLocally(messages: Set<MessageRecord>, displayedMessage: String)
fun deleteMessages(messages: Set<MessageRecord>, 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<Unit>
suspend fun deleteMessageWithoutUnsendRequest(threadId: Long, messages: Set<MessageRecord>): Result<Unit>
suspend fun deleteCommunityMessagesRemotely(threadId: Long, messages: Set<MessageRecord>)
suspend fun delete1on1MessagesRemotely(
threadId: Long,
recipient: Recipient,
messages: Set<MessageRecord>
)
suspend fun deleteNoteToSelfMessagesRemotely(
threadId: Long,
recipient: Recipient,
messages: Set<MessageRecord>
)
suspend fun deleteLegacyGroupMessagesRemotely(
recipient: Recipient,
messages: Set<MessageRecord>
)
fun buildUnsendRequest(recipient: Recipient, message: MessageRecord): UnsendRequest?
suspend fun banUser(threadId: Long, recipient: Recipient): Result<Unit>
suspend fun banAndDeleteAll(threadId: Long, recipient: Recipient): Result<Unit>
suspend fun deleteThread(threadId: Long): Result<Unit>
@@ -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<MessageRecord>, 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<MessageRecord>, 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<MessageRecord>
) {
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<Unit> {
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<MessageRecord>
) {
// 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<MessageRecord>
) {
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<MessageRecord>
) {
// 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<MessageRecord>
): Result<Unit> = kotlin.runCatching {
withContext(Dispatchers.Default) {
val openGroup = lokiThreadDb.getOpenGroupChat(threadId)
if (openGroup != null) {
val messageServerIDs = mutableMapOf<Long, MessageRecord>()
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<Unit> = runCatching {
val accountID = recipient.address.toString()
val openGroup = lokiThreadDb.getOpenGroupChat(threadId)!!

View File

@@ -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 <T> TitledRadioButton(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(
horizontal = LocalDimensions.current.spacing,
vertical = LocalDimensions.current.smallSpacing
),
option: RadioOption<T>,
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

View File

@@ -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

View File

@@ -1,11 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?colorAccent">
<path
android:fillColor="@android:color/white"
android:pathData="m12,2.4355c-5.2796,0 -9.5645,4.2848 -9.5645,9.5645 0,5.2796 4.2848,9.5645 9.5645,9.5645 5.2796,0 9.5645,-4.2848 9.5645,-9.5645 0,-5.2796 -4.2848,-9.5645 -9.5645,-9.5645zM12.123,7.9375 L15.6777,11.4922 14.9707,12.1992 12.623,9.8515v6.1797h-1v-6.1797l-1.9961,1.9941 -0.3535,0.3535 -0.707,-0.707 0.3535,-0.3535 3.2031,-3.2012z"
android:strokeWidth=".95645"/>
</vector>

View File

@@ -2,11 +2,12 @@
android:width="24dp"
android:height="24dp"
android:viewportWidth="50"
android:viewportHeight="50">
android:viewportHeight="50"
android:tint="?attr/colorControlNormal">
<path
android:pathData="M14.295,11.247H18.146V6.687C18.146,4.876 19.089,3.851 20.999,3.851H29.237V13.259C29.237,15.691 30.518,16.956 32.935,16.956H41.606V33.231C41.606,35.059 40.648,36.068 38.738,36.068H35.057V39.918H39.074C43.272,39.918 45.458,37.697 45.458,33.471V17.892C45.458,15.308 44.92,13.658 43.368,12.067L33.565,2.093C32.096,0.587 30.328,0 28.065,0H20.679C16.481,0 14.295,2.218 14.295,6.448V11.247ZM32.452,12.773V5.46L40.604,13.741H33.404C32.73,13.741 32.452,13.447 32.452,12.773Z"
android:fillColor="#000000"/>
android:fillColor="#FFFFFF"/>
<path
android:pathData="M4.571,43.552C4.571,47.798 6.744,50 10.955,50H29.353C33.563,50 35.737,47.779 35.737,43.552V28.424C35.737,25.791 35.403,24.559 33.756,22.88L23.103,12.062C21.52,10.448 20.172,10.082 17.805,10.082H10.955C6.76,10.082 4.571,12.283 4.571,16.529V43.552ZM8.422,43.313V16.753C8.422,14.957 9.365,13.932 11.278,13.932H17.318V24.693C17.318,27.509 18.711,28.882 21.491,28.882H31.882V43.313C31.882,45.14 30.923,46.149 29.03,46.149H11.262C9.365,46.149 8.422,45.14 8.422,43.313ZM21.872,25.486C21.061,25.486 20.715,25.143 20.715,24.328V14.688L31.347,25.486H21.872Z"
android:fillColor="#000000"/>
android:fillColor="#FFFFFF"/>
</vector>

View File

@@ -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"/>
</vector>
</vector>

View File

@@ -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"/>
</vector>
</vector>

View File

@@ -386,4 +386,23 @@
android:layout_width="match_parent"
android:layout_height="match_parent" />
<RelativeLayout
android:id="@+id/loader"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#A4000000"
android:focusable="true"
android:clickable="true"
android:visibility="gone">
<com.github.ybq.android.spinkit.SpinKitView
style="@style/SpinKitView.Large.ThreeBounce"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_marginTop="8dp"
app:SpinKit_Color="@android:color/white" />
</RelativeLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -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"/>
</RelativeLayout>

View File

@@ -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"/>
</FrameLayout>

View File

@@ -27,7 +27,7 @@
android:visibility="gone"
app:tint="?android:textColorTertiary"
tools:src="@drawable/ic_timer"
tools:visibility="visible"/>
tools:visibility="visible" />
<org.thoughtcrime.securesms.conversation.v2.components.ExpirationTimerView
android:id="@+id/expirationTimerView"
@@ -37,47 +37,57 @@
android:visibility="gone"
app:tint="?android:textColorTertiary"
tools:src="@drawable/ic_timer"
tools:visibility="visible"/>
tools:visibility="visible" />
<TextView
android:id="@+id/textView"
android:contentDescription="@string/AccessibilityId_control_message"
android:layout_width="wrap_content"
<LinearLayout
android:id="@+id/controlContentView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="?android:textColorTertiary"
android:textSize="@dimen/very_small_font_size"
tools:text="You disabled disappearing messages" />
<FrameLayout
android:id="@+id/call_view"
style="@style/CallMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
android:orientation="vertical">
<TextView
android:id="@+id/call_text_view"
android:textColor="?message_received_text_color"
android:textAlignment="center"
android:layout_gravity="center"
android:gravity="center"
tools:text="You missed a call"
android:id="@+id/textView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:drawableStartCompat="@drawable/ic_missed_call" />
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" />
</FrameLayout>
<FrameLayout
android:id="@+id/call_view"
style="@style/CallMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/followSetting"
style="@style/Widget.Session.Button.Common.Borderless"
android:layout_marginTop="4dp"
android:textColor="@color/accent_green"
android:textSize="@dimen/very_small_font_size"
android:text="@string/disappearingMessagesFollowSetting"
android:contentDescription="@string/AccessibilityId_disappearingMessagesFollowSetting"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
<TextView
android:id="@+id/call_text_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:gravity="center"
android:textAlignment="center"
android:textColor="?message_received_text_color"
app:drawableStartCompat="@drawable/ic_missed_call"
tools:text="You missed a call" />
</FrameLayout>
<TextView
android:id="@+id/followSetting"
style="@style/Widget.Session.Button.Common.Borderless"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:background="@null"
android:contentDescription="@string/AccessibilityId_disappearingMessagesFollowSetting"
android:text="@string/disappearingMessagesFollowSetting"
android:textColor="@color/accent_green"
android:textSize="@dimen/very_small_font_size" />
</LinearLayout>
</LinearLayout>

View File

@@ -2,12 +2,11 @@
<org.thoughtcrime.securesms.conversation.v2.messages.DeletedMessageView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
xmlns:tools="http://schemas.android.com/tools"
android:orientation="horizontal"
android:padding="@dimen/small_spacing"
android:gravity="center">
android:padding="@dimen/small_spacing">
<ImageView
android:id="@+id/deletedMessageViewIconImageView"

View File

@@ -24,6 +24,7 @@
android:id="@+id/deletedMessageView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintHorizontal_bias="0"
/>
<include layout="@layout/view_pending_attachment"

View File

@@ -41,8 +41,6 @@
<attr name="conversation_editor_text_color" format="reference|color"/>
<attr name="conversation_input_background" format="reference"/>
<attr name="conversation_input_inline_attach_icon_tint" format="reference"/>
<attr name="conversation_transport_sms_indicator" format="reference"/>
<attr name="conversation_transport_push_indicator" format="reference"/>
<attr name="conversation_transport_popup_background" format="reference"/>
<attr name="conversation_emoji_toggle" format="reference"/>
<attr name="conversation_sticker_toggle" format="reference"/>

View File

@@ -164,7 +164,7 @@
<item name="android:background">@drawable/unimportant_dialog_text_button_background</item>
<item name="android:textStyle">bold</item>
</style>
<style name="Widget.Session.Button.Dialog.DangerText">
<item name="android:background">@drawable/danger_dialog_text_button_background</item>
<item name="android:textColor">?danger</item>

View File

@@ -21,7 +21,6 @@
<item name="android:backgroundDimEnabled">true</item>
<item name="android:backgroundDimAmount">0.6</item>
<item name="dialogCornerRadius">@dimen/dialog_corner_radius</item>
<item name="conversation_transport_sms_indicator">@drawable/ic_arrow_up_circle_24</item>
<item name="android:alertDialogTheme">@style/ThemeOverlay.Session.AlertDialog</item>
<item name="alertDialogTheme">@style/ThemeOverlay.Session.AlertDialog</item>
<item name="conversationMenuSearchTintColor">?android:textColorPrimary</item>
@@ -173,8 +172,6 @@
<item name="conversation_editor_background">#22ffffff</item>
<item name="conversation_editor_text_color">#ffeeeeee</item>
<item name="conversation_input_inline_attach_icon_tint">@color/core_grey_05</item>
<item name="conversation_transport_sms_indicator">@drawable/ic_arrow_up_circle_24</item>
<item name="conversation_transport_push_indicator">@drawable/ic_arrow_up_circle_24</item>
<item name="conversation_transport_popup_background">@color/black</item>
<item name="conversation_attach_camera">@drawable/ic_photo_camera_dark</item>
<item name="conversation_attach_image">@drawable/ic_image_dark</item>

View File

@@ -23,6 +23,6 @@ class FirebasePushService : FirebaseMessagingService() {
override fun onMessageReceived(message: RemoteMessage) {
Log.d(TAG, "Received a push notification.")
pushReceiver.onPush(message.data)
pushReceiver.onPushDataReceived(message.data)
}
}

View File

@@ -1,5 +1,6 @@
package org.thoughtcrime.securesms.conversation.v2
import android.app.Application
import com.goterl.lazysodium.utils.KeyPair
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.first
@@ -12,7 +13,6 @@ import org.junit.Before
import org.junit.Test
import org.mockito.Mockito
import org.mockito.Mockito.anyLong
import org.mockito.Mockito.anySet
import org.mockito.Mockito.verify
import org.mockito.kotlin.any
import org.mockito.kotlin.mock
@@ -27,6 +27,7 @@ class ConversationViewModelTest: BaseViewModelTest() {
private val repository = mock<ConversationRepository>()
private val storage = mock<Storage>()
private val application = mock<Application>()
private val threadId = 123L
private val edKeyPair = mock<KeyPair>()
@@ -41,7 +42,10 @@ class ConversationViewModelTest: BaseViewModelTest() {
storage = storage,
messageDataProvider = mock(),
groupDb = mock(),
threadDb = mock()
threadDb = mock(),
textSecurePreferences = mock(),
lokiMessageDb = mock(),
application = mock(),
)
}
@@ -94,59 +98,27 @@ class ConversationViewModelTest: BaseViewModelTest() {
verify(repository).setBlocked(threadId, recipient, false)
}
@Test
fun `should delete locally`() {
val message = mock<MessageRecord>()
viewModel.deleteLocally(message)
verify(repository).deleteLocally(recipient, message)
}
@Test
fun `should emit error message on failure to delete a message for everyone`() = runBlockingTest {
val message = mock<MessageRecord>()
val error = Throwable()
whenever(repository.deleteForEveryone(anyLong(), any(), any()))
.thenReturn(Result.failure(error))
viewModel.deleteForEveryone(message)
assertThat(viewModel.uiState.first().uiMessages.first().message, endsWith("$error"))
}
@Test
fun `should emit error message on failure to delete messages without unsend request`() =
runBlockingTest {
val message = mock<MessageRecord>()
val error = Throwable()
whenever(repository.deleteMessageWithoutUnsendRequest(anyLong(), anySet()))
.thenReturn(Result.failure(error))
viewModel.deleteMessagesWithoutUnsendRequest(setOf(message))
assertThat(viewModel.uiState.first().uiMessages.first().message, endsWith("$error"))
}
@Test
fun `should emit error message on ban user failure`() = runBlockingTest {
val error = Throwable()
whenever(repository.banUser(anyLong(), any())).thenReturn(Result.failure(error))
whenever(application.getString(any())).thenReturn("Ban failed")
viewModel.banUser(recipient)
assertThat(viewModel.uiState.first().uiMessages.first().message, endsWith("$error"))
assertThat(viewModel.uiState.first().uiMessages.first().message, equalTo("Ban failed"))
}
@Test
fun `should emit a message on ban user success`() = runBlockingTest {
whenever(repository.banUser(anyLong(), any())).thenReturn(Result.success(Unit))
whenever(application.getString(any())).thenReturn("User banned")
viewModel.banUser(recipient)
assertThat(
viewModel.uiState.first().uiMessages.first().message,
equalTo("Successfully banned user")
equalTo("User banned")
)
}
@@ -154,21 +126,23 @@ class ConversationViewModelTest: BaseViewModelTest() {
fun `should emit error message on ban user and delete all failure`() = runBlockingTest {
val error = Throwable()
whenever(repository.banAndDeleteAll(anyLong(), any())).thenReturn(Result.failure(error))
whenever(application.getString(any())).thenReturn("Ban failed")
viewModel.banAndDeleteAll(messageRecord)
assertThat(viewModel.uiState.first().uiMessages.first().message, endsWith("$error"))
assertThat(viewModel.uiState.first().uiMessages.first().message, equalTo("Ban failed"))
}
@Test
fun `should emit a message on ban user and delete all success`() = runBlockingTest {
whenever(repository.banAndDeleteAll(anyLong(), any())).thenReturn(Result.success(Unit))
whenever(application.getString(any())).thenReturn("User banned")
viewModel.banAndDeleteAll(messageRecord)
assertThat(
viewModel.uiState.first().uiMessages.first().message,
equalTo("Successfully banned user and deleted all their messages")
equalTo("User banned")
)
}
@@ -176,6 +150,8 @@ class ConversationViewModelTest: BaseViewModelTest() {
fun `should remove shown message`() = runBlockingTest {
// Given that a message is generated
whenever(repository.banUser(anyLong(), any())).thenReturn(Result.success(Unit))
whenever(application.getString(any())).thenReturn("User banned")
viewModel.banUser(recipient)
assertThat(viewModel.uiState.value.uiMessages.size, equalTo(1))
// When the message is shown