Newer
Older
* Copyright (C) 2004-2023 Savoir-faire Linux Inc.
* 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
* 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
* 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
* 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.VCardUtils
import java.io.File
import javax.inject.Inject
import javax.inject.Named
class ConversationPresenter @Inject constructor(
val callService: CallService,
private val accountService: AccountService,
private val hardwareService: HardwareService,
val conversationFacade: ConversationFacade,
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)
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) }
}
.subscribe(
{ (account, conversation) ->
setConversation(account, conversation)
}
) { error: Throwable ->
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) {
Log.w(TAG, "initContact ${c.uri} mode: ${c.mode}")
if (c.mode === Conversation.Mode.Syncing) {
} 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()
view.switchToIncomingTrustRequestView(req.profile?.blockingGet()?.displayName ?: c.uriTitle)
}
}
} else {
view.switchToConversationView()
}
}
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)
.doOnNext { convViewModel -> initContact(account, convViewModel, this.view!!) }
disposable.add(Observable.combineLatest(hardwareService.connectivityState, accountService.getObservableAccount(account))
{ isConnected: Boolean, a: Account -> isConnected || a.isRegistered }
Adrien Béraud
committed
.subscribe { isOk: Boolean ->
this.view?.let { v ->
if (!isOk) v.displayNetworkErrorPanel() else if (!account.isEnabled) {
v.displayAccountOfflineErrorPanel()
} else {
v.hideErrorPanel()
Adrien Béraud
committed
}
})
.subscribe({ conversation -> this.view?.refreshView(conversation) })
{ e -> Log.e(TAG, "Can't update element", e) })
.subscribe({ conversation -> this.view?.refreshView(conversation) })
{ e -> Log.e(TAG, "Can't update elements", e) })
.switchMap { contacts -> Observable.merge(contactService.observeLoadedContact(c.accountId, contacts, true)) }
.subscribe { contact: ContactViewModel -> this.view?.updateContact(contact) })
val v = this.view ?: return@subscribe
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
.subscribe { composingStatus: ComposingStatus -> this.view?.setComposingStatus(composingStatus) })
disposable.add(
Observable.combineLatest(
mConversationSubject.switchMap { it.calls },
mConversationSubject.switchMap { it.activeCallsObservable })
{ _, activeCalls: List<Conversation.ActiveCall> -> activeCalls }
.subscribe({
updateOngoingCallView(c)
updateRingingCallView(it)
}) { e: Throwable ->
Log.e(TAG, "Can't update call view", e)
})
disposable.add(c.getColor()
.subscribe({ integer: Int -> this.view?.setConversationColor(integer) }) { e: Throwable ->
Log.e(TAG, "Can't update conversation color", e)
})
disposable.add(c.getSymbol()
.subscribe({ symbol: CharSequence -> this.view?.setConversationSymbol(symbol) }) { e: Throwable ->
Log.e(TAG, "Can't update conversation color", e)
})
disposable.add(account
.getLocationUpdates(c.uri)
.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) {
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)
mCompositeDisposable.add(mConversationSubject.firstElement().subscribe({ conversation ->
conversationFacade.sendFile(conversation, conversation.uri, file).subscribe()
}
/**
* 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)
}
}
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
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.
*/
// Check if the device has a camera or microphone
if (!withCamera && !hardwareService.hasMicrophone()) {
view!!.displayErrorToast(Error.NO_MICROPHONE)
return
}
// Check if it's a group call
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() {
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!!
}
}