mirror of
https://github.com/oxen-io/session-android.git
synced 2025-08-11 14:37:45 +00:00
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:
@@ -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"
|
||||
|
@@ -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?) {
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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? =
|
||||
|
@@ -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 {
|
@@ -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 {
|
@@ -4,6 +4,7 @@ import android.content.Context
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.annotation.ColorInt
|
||||
|
||||
|
||||
/**
|
||||
* Represents an action to be rendered
|
||||
*/
|
||||
|
@@ -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>) {
|
||||
|
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
|
@@ -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 = {}
|
||||
)
|
||||
}
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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
|
||||
}
|
||||
|
||||
|
@@ -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)
|
||||
|
@@ -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(){
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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);
|
||||
|
@@ -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) {
|
||||
|
@@ -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;
|
||||
|
@@ -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 = ?"
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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()
|
||||
|
@@ -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?
|
||||
)
|
||||
}
|
||||
|
@@ -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 ->
|
||||
|
@@ -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)!!
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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>
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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>
|
@@ -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"
|
||||
|
@@ -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"
|
||||
|
@@ -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"/>
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user