Skip to content
Snippets Groups Projects
ConversationPresenter.kt 22.7 KiB
Newer Older
 *  Copyright (C) 2004-2023 Savoir-faire Linux Inc.
Adrien Béraud's avatar
Adrien Béraud committed
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
Adrien Béraud's avatar
Adrien Béraud committed
 *  the Free Software Foundation, either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
Adrien Béraud's avatar
Adrien Béraud committed
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU General Public License
Adrien Béraud's avatar
Adrien Béraud committed
 *  along with this program. If not, see <https://www.gnu.org/licenses/>.
 */
package net.jami.conversation

import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.core.Scheduler
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import io.reactivex.rxjava3.subjects.BehaviorSubject
import io.reactivex.rxjava3.subjects.PublishSubject
import io.reactivex.rxjava3.subjects.Subject
import net.jami.daemon.Blob
import net.jami.services.ConversationFacade
import net.jami.model.*
import net.jami.model.Account.ComposingStatus
import net.jami.model.Conversation.ElementStatus
import net.jami.mvp.RootPresenter
import net.jami.services.*
import net.jami.smartlist.ConversationItemViewModel
import net.jami.utils.Log
import net.jami.utils.VCardUtils
import java.io.File
import javax.inject.Inject
import javax.inject.Named

class ConversationPresenter @Inject constructor(
    val callService: CallService,
    val contactService: ContactService,
    private val accountService: AccountService,
    private val hardwareService: HardwareService,
    val conversationFacade: ConversationFacade,
    private val vCardService: VCardService,
    val deviceRuntimeService: DeviceRuntimeService,
    private val preferencesService: PreferencesService,
    @param:Named("UiScheduler") private val uiScheduler: Scheduler
) : RootPresenter<ConversationView>() {
    private var mConversation: Conversation? = null
    private var mConversationUri: Uri? = null
    private var mConversationDisposable: CompositeDisposable? = null
    private val mVisibilityDisposable = CompositeDisposable().apply {
        mCompositeDisposable.add(this)
    }
    private val mConversationSubject: Subject<Conversation> = BehaviorSubject.create()
    private var searchQuerySubject: Subject<String>? = null

    fun init(conversationUri: Uri, accountId: String) {
        if (conversationUri == mConversationUri) return
        Log.w(TAG, "init $conversationUri $accountId")
        val settings = preferencesService.settings
        view?.setSettings(settings.enableLinkPreviews)
        mConversationUri = conversationUri
        mCompositeDisposable.add(conversationFacade.getAccountSubject(accountId)
            .flatMapObservable { account: Account ->
                val conversation = account.getByUri(conversationUri)!!
                conversation.mode.flatMap { conversationMode: Conversation.Mode ->
                    if (conversationMode === Conversation.Mode.Legacy)
                        conversation.contact!!.conversationUri.switchMapSingle { uri ->
                            conversationFacade.startConversation(accountId, uri)
                        }
                    else
                        Observable.just(conversation)
                }.switchMapSingle { conversation ->
                    conversationFacade.loadConversationHistory(conversation)
                        .map { Pair(account, it) }
                }
            .observeOn(uiScheduler)
            .subscribe(
                { (account, conversation) ->
                    setConversation(account, conversation)
                }
            ) { error: Throwable ->
                view?.goToHome()
                Log.e(TAG, "Error loading conversation", error)
            }
        )
    }

    override fun unbindView() {
        super.unbindView()
        mConversation = null
        mConversationUri = null
        mConversationDisposable?.let { conversationDisposable ->
            conversationDisposable.dispose()
            mConversationDisposable = null
        }
    }

    private fun setConversation(account: Account, conversation: Conversation) {
        Log.w(TAG, "setConversation ${conversation.aggregateHistory.size}")
        if (mConversation == conversation) return
        mConversation = conversation
        mConversationSubject.onNext(conversation)
        view?.let { initView(account, conversation, it) }
    }

    fun pause() {
        mVisibilityDisposable.clear()
        mConversation?.isVisible = false
    }

    fun resume(isBubble: Boolean) {
        Log.w(TAG, "resume $mConversationUri")
        mVisibilityDisposable.clear()
        mVisibilityDisposable.add(
            mConversationSubject
            .subscribe({ conversation: Conversation ->
                conversation.isVisible = true
                accountService.getAccount(conversation.accountId)?.let { account ->
                    conversationFacade.readMessages(account, conversation, !isBubble)}
            }) { e -> Log.e(TAG, "Error loading conversation", e) })
    }

    private fun initContact(account: Account, c: ConversationItemViewModel, view: ConversationView) {
        if (account.isJami) {
            Log.w(TAG, "initContact ${c.uri} mode: ${c.mode}")
            if (c.mode === Conversation.Mode.Syncing) {
                view.switchToSyncingView()
            } else if (c.mode == Conversation.Mode.Request) {
                view.switchToIncomingTrustRequestView(c.uriTitle/*ConversationItemViewModel.getUriTitle(conversation.uri, contacts)*/)
            } else if (c.isSwarm || account.isContact(c.uri)) {
                if ((c.mode == Conversation.Mode.OneToOne) && c.getContact()?.contact?.isBanned == true) {
                    view.switchToBannedView()
                } else
                    view.switchToConversationView()
                val req = c.request
                if (req == null) {
                    view.switchToUnknownView(c.uriTitle)
                    view.switchToIncomingTrustRequestView(req.profile?.blockingGet()?.displayName ?: c.uriTitle)
                }
            }
        } else {
            view.switchToConversationView()
        }
        view.displayContact(c)
    }

    private fun initView(account: Account, c: Conversation, view: ConversationView) {
        Log.w(TAG, "initView ${c.uri}")
        val disposable = mConversationDisposable?.apply { clear() } ?: CompositeDisposable().apply {
            mConversationDisposable = this
            mCompositeDisposable.add(this)
        }

        view.hideNumberSpinner()
        disposable.add(c.mode
            .switchMap { mode: Conversation.Mode ->
                conversationFacade.observeConversation(account, c, true)
                    .observeOn(uiScheduler)
                    .doOnNext { convViewModel -> initContact(account, convViewModel, this.view!!) }
        disposable.add(Observable.combineLatest(hardwareService.connectivityState, accountService.getObservableAccount(account))
            { isConnected: Boolean, a: Account -> isConnected || a.isRegistered }
            .observeOn(uiScheduler)
            .subscribe { isOk: Boolean ->
                this.view?.let { v ->
                    if (!isOk) v.displayNetworkErrorPanel() else if (!account.isEnabled) {
                        v.displayAccountOfflineErrorPanel()
                    } else {
                        v.hideErrorPanel()
        disposable.add(c.sortedHistory
            .observeOn(uiScheduler)
            .subscribe({ conversation -> this.view?.refreshView(conversation) })
                { e -> Log.e(TAG, "Can't update element", e) })
        disposable.add(c.cleared
            .observeOn(uiScheduler)
            .subscribe({ conversation -> this.view?.refreshView(conversation) })
                { e -> Log.e(TAG, "Can't update elements", e) })
        disposable.add(c.contactUpdates
            .switchMap { contacts -> Observable.merge(contactService.observeLoadedContact(c.accountId, contacts, true)) }
            .observeOn(uiScheduler)
            .subscribe { contact: ContactViewModel -> this.view?.updateContact(contact) })
        disposable.add(c.updatedElements
            .observeOn(uiScheduler)
            .subscribe({ elementTuple ->
                val v = this.view ?: return@subscribe
                when (elementTuple.second) {
                    ElementStatus.ADD -> v.addElement(elementTuple.first)
                    ElementStatus.UPDATE -> v.updateElement(elementTuple.first)
                    ElementStatus.REMOVE -> v.removeElement(elementTuple.first)
            }, { e: Throwable -> Log.e(TAG, "Can't update element", e) }))
        if (showTypingIndicator()) {
            disposable.add(c.composingStatus
                .observeOn(uiScheduler)
                .subscribe { composingStatus: ComposingStatus -> this.view?.setComposingStatus(composingStatus) })
        disposable.add(
            Observable.combineLatest(
                mConversationSubject.switchMap { it.calls },
                mConversationSubject.switchMap { it.activeCallsObservable })
            { _, activeCalls: List<Conversation.ActiveCall> -> activeCalls }
            .observeOn(uiScheduler)
            .subscribe({
                updateOngoingCallView(c)
                updateRingingCallView(it)
            }) { e: Throwable ->
                Log.e(TAG, "Can't update call view", e)
            })
        disposable.add(c.getColor()
            .observeOn(uiScheduler)
            .subscribe({ integer: Int -> this.view?.setConversationColor(integer) }) { e: Throwable ->
                Log.e(TAG, "Can't update conversation color", e)
            })
        disposable.add(c.getSymbol()
            .observeOn(uiScheduler)
            .subscribe({ symbol: CharSequence -> this.view?.setConversationSymbol(symbol) }) { e: Throwable ->
                Log.e(TAG, "Can't update conversation color", e)
            })
        disposable.add(account
            .getLocationUpdates(c.uri)
            .observeOn(uiScheduler)
            .subscribe {
                Log.e(TAG, "getLocationUpdates: update")
                this.view?.showMap(c.accountId, c.uri.uri, false)
        mConversationDisposable?.add(accountService.loadMore(mConversation!!).subscribe({}) {})
    fun scrollToMessage(messageId: String) {
        if(mConversation!!.getMessage(messageId) != null) {
            view?.scrollToMessage(messageId)
        } else {
            mConversationDisposable?.add(accountService.loadUntil(mConversation!!, until = messageId)
                .observeOn(uiScheduler)
                .subscribe { _ -> this.view?.scrollToMessage(messageId)})
        }
    }

    fun openContact() {
        mConversation?.let { conversation -> view?.goToContactActivity(conversation.accountId, conversation.uri) }
    }

    fun sendTextMessage(message: String?, replyTo: Interaction? = null) {
        val conversation = mConversation
        if (message.isNullOrEmpty() || conversation == null) {
            return
        }
        val conference = conversation.currentCall
        if (conversation.isSwarm || conference == null || !conference.isOnGoing) {
            conversationFacade.sendTextMessage(conversation, conversation.uri, message, replyTo?.messageId).subscribe()
            conversationFacade.sendTextMessage(conversation, conference, message)
    fun sendFile(file: File) {
Adrien Béraud's avatar
Adrien Béraud committed
        mCompositeDisposable.add(mConversationSubject.firstElement().subscribe({ conversation ->
            conversationFacade.sendFile(conversation, conversation.uri, file).subscribe()
Adrien Béraud's avatar
Adrien Béraud committed
        }) {e -> Log.e(TAG, "Can't send file", e)})
    }

    /**
     * Gets the absolute path of the file dataTransfer and sends both the DataTransfer and the
     * found path to the ConversationView in order to start saving the file
     *
     * @param interaction an interaction representing a datat transfer
     */
    fun saveFile(interaction: Interaction) {
        val transfer = interaction as DataTransfer
        val fileAbsolutePath = deviceRuntimeService.getConversationPath(transfer).absolutePath
        view?.startSaveFile(transfer, fileAbsolutePath)
    }

    fun shareFile(file: DataTransfer) {
        val path = deviceRuntimeService.getConversationPath(file)
        view?.shareFile(path, file.displayName)
    }

    fun openFile(interaction: Interaction) {
        val file = interaction as DataTransfer
        val path = deviceRuntimeService.getConversationPath(file)
        view?.openFile(path, file.displayName)
    }

    fun acceptFile(transfer: DataTransfer) {
        view?.acceptFile(mConversation!!.accountId, mConversationUri!!, transfer)
    }

    fun goToGroupCall(media: Boolean) {
        view?.goToGroupCall(mConversation!!, mConversation!!.uri, media)
    }

    fun refuseFile(transfer: DataTransfer) {
        view?.refuseFile(mConversation!!.accountId, mConversationUri!!, transfer)
    }

    fun deleteConversationItem(element: Interaction) {
        conversationFacade.deleteConversationItem(mConversation!!, element)
    fun startReplyTo(interaction: Interaction) {
        view?.startReplyTo(interaction)
    }

    fun cancelMessage(message: Interaction) {
        val conversation = mConversation ?: return
        conversationFacade.cancelMessage(conversation, message)
    }

    private fun sendTrustRequest() {
        val conversation = mConversation ?: return
        val contact = conversation.contact ?: return
        contact.status = Contact.Status.REQUEST_SENT
        vCardService.loadSmallVCardWithDefault(conversation.accountId, VCardService.MAX_SIZE_REQUEST)
            .subscribeOn(Schedulers.computation())
            .subscribe({ vCard -> accountService.sendTrustRequest(conversation, contact.uri, Blob.fromString(VCardUtils.vcardToString(vCard)))})
            { accountService.sendTrustRequest(conversation, contact.uri) }
    }

    fun clickOnGoingPane() {
        val conf = mConversation?.currentCall
        if (conf != null) {
            view?.goToCallActivity(conf.id, conf.hasActiveVideo())
        } else {
            view?.displayOnGoingCallPane(false)
        }
    }

    enum class IncomingCallAction { ACCEPT_AUDIO, ACCEPT_VIDEO, VIEW_ONLY }

    fun clickRingingPane(action: IncomingCallAction) {
        val conference = mConversation?.currentCall

        if (conference != null) {
            when (action) { // One to one call.
                IncomingCallAction.ACCEPT_AUDIO -> {
                    view?.acceptAndGoToCallActivity(conference.firstCall ?: return, false)
                }

                IncomingCallAction.ACCEPT_VIDEO -> {
                    view?.acceptAndGoToCallActivity(conference.firstCall ?: return, true)
                }

                IncomingCallAction.VIEW_ONLY -> {
                    view?.goToCallActivity(conference.id, conference.hasActiveVideo())
                }
            }
        } else { // Case for group calls.
            val conversation = mConversation ?: return
            mCompositeDisposable.add(
                conversation.activeCallsObservable
                    .firstOrError()
                    .subscribe { activeCalls ->
                        val activeCall = activeCalls.firstOrNull()
                        if (activeCall != null) {
                            when (action) {
                                IncomingCallAction.ACCEPT_AUDIO -> {
                                    view?.goToGroupCall(conversation, conversation.uri, false)
                                }

                                IncomingCallAction.ACCEPT_VIDEO -> {
                                    view?.goToGroupCall(conversation, conversation.uri, true)
                                }

                                IncomingCallAction.VIEW_ONLY -> {}
                            }
                        } else { // Seems there is no call ringing.
                            view?.displayRingingCallPane(false)
                        }
                    }
            )
        }
    }

    /**
     * Navigates to the call activity with the specified camera option.
     *
     * @param withCamera Indicates whether the call should include camera functionality.
     */
    fun goToCall(withCamera: Boolean) {
        // Check if the device has a camera or microphone
        if (!withCamera && !hardwareService.hasMicrophone()) {
            view!!.displayErrorToast(Error.NO_MICROPHONE)
            return
        }
        if (isSwarmGroup()) {
            goToGroupCall(withCamera)
            return
        }

        // Get the conversation and navigate to the call activity
        mCompositeDisposable.add(mConversationSubject
            .firstElement()
            .subscribe { conversation: Conversation ->
                val view = view
                if (view != null) {
                    val conf = conversation.currentCall
                    if (conf != null && conf.participants.isNotEmpty()
                        && conf.participants[0].callStatus !== Call.CallStatus.INACTIVE
                        && conf.participants[0].callStatus !== Call.CallStatus.FAILURE) {
                        // Navigate to the call activity with the existing call
                        view.goToCallActivity(conf.id, conf.hasActiveVideo())
                        // Navigate to the call activity with the conversation details
                        view.goToCallActivityWithResult(
                            conversation.accountId, conversation.uri,
                            conversation.contact!!.uri, withCamera
                        )
                    }
                }
            })
    }

    private fun updateOngoingCallView(conversation: Conversation?) {
        val conf = conversation?.currentCall
        view?.displayOnGoingCallPane(
            conf?.state === Call.CallStatus.CURRENT || conf?.state === Call.CallStatus.HOLD
        )
    }

    private fun updateRingingCallView(activeCalls: List<Conversation.ActiveCall>) {
        val call = mConversation?.currentCall
        val activeCall = activeCalls.isNotEmpty() // Group call
        view?.displayRingingCallPane(
            display = (call?.state === Call.CallStatus.RINGING) ||
                    (call == null && activeCall),
            withCamera = (call?.hasActiveVideo() ?: false) || activeCall
        )
    }

    fun onBlockIncomingContactRequest() {
        mConversation?.let { conversation ->
            conversationFacade.banConversation(conversation.accountId, conversation.uri)
            conversationFacade.discardRequest(conversation.accountId, conversation.uri)
        }
        view?.goToHome()
    }

    fun onRefuseIncomingContactRequest() {
        mConversation?.let { conversation ->
            conversationFacade.discardRequest(conversation.accountId, conversation.uri)
        }
        view?.goToHome()
    }

    fun onAcceptIncomingContactRequest() {
        mConversation?.let { conversation ->
            if (conversation.mode.blockingFirst() == Conversation.Mode.Request) {
                conversation.loaded = null
                conversation.clearHistory(true)
                conversation.setMode(Conversation.Mode.Syncing)
            }
            conversationFacade.acceptRequest(conversation.accountId, conversation.uri)
        }
        view?.switchToConversationView()
    }

    fun onAddContact() {
        sendTrustRequest()
        view?.switchToConversationView()
    }

    fun noSpaceLeft() {
        Log.e(TAG, "configureForFileInfoTextMessage: no space left on device")
        view?.displayErrorToast(Error.NO_SPACE_LEFT)
    }

    fun cameraPermissionChanged(isGranted: Boolean) {
        if (isGranted && hardwareService.isVideoAvailable) {
            hardwareService.initVideo()
                .onErrorComplete()
                .subscribe()
        }
    }

    fun shareLocation() {
Adrien Béraud's avatar
Adrien Béraud committed
        mCompositeDisposable.add(mConversationSubject.firstElement()
            .subscribe { conversation -> view?.startShareLocation(conversation.accountId, conversation.uri.uri) })
    }

    fun showPluginListHandlers() {
        view?.showPluginListHandlers(mConversation!!.accountId, mConversationUri!!.uri)
    }

    val path: Pair<String, Uri>
        get() = Pair(mConversation!!.accountId, mConversationUri!!)

    fun onComposingChanged(hasMessage: Boolean) {
        if (showTypingIndicator()) {
            mConversation?.let { conversation ->
                conversationFacade.setIsComposing(conversation.accountId, conversation.uri, hasMessage)
    fun isGroup() = mConversation?.isGroup() ?: false

    private fun isSwarmGroup() = mConversation?.isSwarmGroup() ?: false
    private fun showTypingIndicator(): Boolean {
        return preferencesService.settings.enableTypingIndicator
    fun setSearchQuery(query: String) {
        searchQuerySubject?.onNext(query)
    }

    fun startSearch() {
        if (searchQuerySubject == null) {
            val conversation = mConversation ?: return
            mCompositeDisposable.add(PublishSubject.create<String>()
                .apply { searchQuerySubject = this }
                .switchMap { accountService.searchConversation(conversation.accountId, conversation.uri, it) }
                .observeOn(uiScheduler)
                .subscribe { view?.addSearchResults(it.results) })
        }
    }

    fun stopSearch() {
        searchQuerySubject?.let {
            it.onComplete()
            searchQuerySubject = null
        }
    }

    fun sendReaction(interaction: Interaction, text: CharSequence) {
        val conversation = mConversation ?: return
        accountService.sendConversationReaction(conversation.accountId, conversation.uri, text.toString(), interaction.messageId ?: return)
    }

    fun shareText(interaction: TextMessage) {
        view!!.shareText(interaction.body ?: return)
    }

     * Remove the reaction (emoji)
     * @param reactionToRemove
     */
    fun removeReaction(reactionToRemove: Interaction) {
        val conversation = mConversation ?: return
        accountService.editConversationMessage(
            conversation.accountId, conversation.uri, "", reactionToRemove.messageId!!
        )
    }

    fun editMessage(accountId: String, conversationUri: Uri, messageId: String, newMessage: String) {
        val message = mConversation?.getMessage(messageId)
        if (message?.body != newMessage)
            accountService.editConversationMessage(accountId, conversationUri, newMessage, messageId)
    companion object {
        private val TAG = ConversationPresenter::class.simpleName!!
    }
}