@ -1,6 +1,8 @@
package org.thoughtcrime.securesms.conversation.v2
import android.app.Application
import android.content.Context
import android.widget.Toast
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
@ -10,13 +12,14 @@ import dagger.assisted.AssistedInject
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 org.session.libsession.database.MessageDataProvider
import org.session.libsession.messaging.messages.ExpirationConfiguration
import org.session.libsession.messaging.open_groups.OpenGroup
@ -25,25 +28,32 @@ import org.session.libsession.messaging.sending_receiving.attachments.DatabaseAt
import org.session.libsession.messaging.utilities.AccountId
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.thoughtcrime.securesms.database.MmsDatabase
import org.thoughtcrime.securesms.audio.AudioSlidePlayer
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 : Storage ,
private val messageDataProvider : MessageDataProvider ,
database : MmsDatabase ,
private val lokiMessageDb : LokiMessageDatabase ,
private val textSecurePreferences : TextSecurePreferences
) : ViewModel ( ) {
val showSendAfterApprovalText : Boolean
@ -52,8 +62,44 @@ class ConversationViewModel(
private val _uiState = MutableStateFlow ( ConversationUiState ( conversationExists = true ) )
val uiState : StateFlow < ConversationUiState > = _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 )
@ -180,55 +226,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 } && (
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 ( )
)
)
)
}
}
}
}
}
/ * *
* Stops audio player if its current playing is the one given in the message .
* 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 "
* /
private fun stopPlayingAudioMessage ( message : MessageRecord ) {
val mmsMessage = message as ? MmsMessageRecord ?: return
val audioSlide = mmsMessage . slideDeck . audioSlide ?: return
AudioSlidePlayer . getInstance ( ) ?. takeIf { it . audioSlide == audioSlide } ?. stop ( )
}
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, remove it from the db instead
if ( messages . all { it . isDeleted } ) {
// 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 )
)
}
fun setRecipientApproved ( ) {
val recipient = recipient ?: return Log . w ( " Loki " , " Recipient was null for set approved action " )
repository . setApproved ( recipient , true )
// show confirmation toast
Toast . makeText (
application ,
application . resources . getQuantityString ( R . plurals . deleteMessageDeleted , messages . count ( ) , messages . count ( ) ) ,
Toast . LENGTH _SHORT
) . show ( )
}
fun deleteForEveryone ( message : MessageRecord ) = viewModelScope . launch {
/ * *
* 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. " )
stopPlayingAudioMessage ( message )
repository . deleteForEveryone ( threadId , recipient , message )
. onSuccess {
Log . d ( " Loki " , " Deleted message ${message.id} " )
stopPlayingAudioMessage ( message )
// 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 ) }
}
. onFailure {
Log . w ( " Loki " , " FAILED TO delete message ${message.id} " )
showMessage ( " Couldn't delete message due to error: $it " )
// 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 ) }
}
}
fun deleteMessagesWithoutUnsendRequest ( messages : Set < MessageRecord > ) = viewModelScope . launch {
repository . deleteMessageWithoutUnsendRequest ( threadId , messages )
. onFailure {
showMessage ( " Couldn't delete message due to error: $it " )
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 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 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 ) )
}
}
@ -237,13 +637,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 ) )
}
}
@ -256,7 +656,7 @@ class ConversationViewModel(
}
}
. onFailure {
showMessage ( " Couldn't accept message request due to error : $it " )
Log . w ( " " , " Failed to accept message request : $it " )
}
}
@ -306,6 +706,40 @@ class ConversationViewModel(
attachmentDownloadHandler . onAttachmentDownloadRequest ( attachment )
}
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
@ -315,23 +749,50 @@ 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 : Storage ,
private val mmsDatabase : MmsDatabase ,
private val messageDataProvider : MessageDataProvider ,
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 ,
database = mmsDatabase
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 )
@ -340,7 +801,8 @@ data class ConversationUiState(
val uiMessages : List < UiMessage > = emptyList ( ) ,
val isMessageRequestAccepted : Boolean ? = null ,
val conversationExists : Boolean ,
val hideInputBar : Boolean = false
val hideInputBar : Boolean = false ,
val showLoader : Boolean = false
)
data class RetrieveOnce < T > ( val retrieval : ( ) -> T ? ) {