/* * Nextcloud Talk application * * @author Mario Danic * Copyright (C) 2017-2019 Mario Danic * * 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 . */ package com.nextcloud.talk.controllers import android.content.ClipData import android.content.Context import android.content.Intent import android.content.res.Resources import android.graphics.PorterDuff import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Parcelable import android.text.Editable import android.text.InputFilter import android.text.TextUtils import android.text.TextWatcher import android.util.Log import android.util.TypedValue import android.view.* import android.widget.* import androidx.emoji.text.EmojiCompat import androidx.emoji.widget.EmojiEditText import androidx.emoji.widget.EmojiTextView import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import butterknife.BindView import butterknife.OnClick import coil.api.load import coil.target.Target import coil.transform.CircleCropTransformation import com.bluelinelabs.conductor.RouterTransaction import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler import com.bluelinelabs.conductor.changehandler.VerticalChangeHandler import com.google.android.flexbox.FlexboxLayout import com.nextcloud.talk.R import com.nextcloud.talk.activities.MagicCallActivity import com.nextcloud.talk.adapters.messages.* import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.callbacks.MentionAutocompleteCallback import com.nextcloud.talk.components.filebrowser.controllers.BrowserController import com.nextcloud.talk.controllers.base.BaseController import com.nextcloud.talk.events.UserMentionClickEvent import com.nextcloud.talk.events.WebSocketCommunicationEvent import com.nextcloud.talk.models.json.chat.ChatMessage import com.nextcloud.talk.models.json.chat.ChatOverall import com.nextcloud.talk.models.json.conversations.Conversation import com.nextcloud.talk.models.json.conversations.RoomOverall import com.nextcloud.talk.models.json.conversations.RoomsOverall import com.nextcloud.talk.models.json.generic.GenericOverall import com.nextcloud.talk.models.json.mention.Mention import com.nextcloud.talk.newarch.local.models.UserNgEntity import com.nextcloud.talk.newarch.local.models.getCredentials import com.nextcloud.talk.newarch.local.models.getMaxMessageLength import com.nextcloud.talk.newarch.utils.Images import com.nextcloud.talk.presenters.MentionAutocompletePresenter import com.nextcloud.talk.utils.* import com.nextcloud.talk.utils.DrawableUtils.getDrawableResourceIdForMimeType import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.text.Spans import com.nextcloud.talk.webrtc.MagicWebSocketInstance import com.nextcloud.talk.webrtc.WebSocketConnectionHelper import com.otaliastudios.autocomplete.Autocomplete import com.stfalcon.chatkit.commons.ImageLoader import com.stfalcon.chatkit.commons.models.IMessage import com.stfalcon.chatkit.messages.MessageHolders import com.stfalcon.chatkit.messages.MessageInput import com.stfalcon.chatkit.messages.MessagesList import com.stfalcon.chatkit.messages.MessagesListAdapter import com.stfalcon.chatkit.utils.DateFormatter import com.uber.autodispose.AutoDispose import com.vanniktech.emoji.EmojiPopup import com.webianks.library.PopupBubble import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.koin.android.ext.android.inject import org.parceler.Parcels import retrofit2.HttpException import retrofit2.Response import java.util.* import java.util.concurrent.TimeUnit class ChatController(args: Bundle) : BaseController(), MessagesListAdapter .OnLoadMoreListener, MessagesListAdapter.Formatter, MessagesListAdapter .OnMessageViewLongClickListener, MessageHolders.ContentChecker { val ncApi: NcApi by inject() @BindView(R.id.messagesListView) @JvmField var messagesListView: MessagesList? = null @BindView(R.id.messageInputView) @JvmField var messageInputView: MessageInput? = null @BindView(R.id.messageInput) @JvmField var messageInput: EmojiEditText? = null @BindView(R.id.popupBubbleView) @JvmField var popupBubble: PopupBubble? = null @BindView(R.id.progressBar) @JvmField var loadingProgressBar: ProgressBar? = null @BindView(R.id.smileyButton) @JvmField var smileyButton: ImageButton? = null @BindView(R.id.lobbyView) @JvmField var lobbyView: RelativeLayout? = null @BindView(R.id.lobbyTextView) @JvmField var conversationLobbyText: TextView? = null @JvmField @BindView(R.id.quotedChatMessageView) var quotedChatMessageView: RelativeLayout? = null var roomToken: String? = null val conversationUser: UserNgEntity? val roomPassword: String var credentials: String? = null var currentConversation: Conversation? = null var inConversation = false var historyRead = false var globalLastKnownFutureMessageId: Long = -1 var globalLastKnownPastMessageId: Long = -1 var adapter: MessagesListAdapter? = null var mentionAutocomplete: Autocomplete<*>? = null var layoutManager: LinearLayoutManager? = null var lookingIntoFuture = false var newMessagesCount = 0 var startCallFromNotification: Boolean? = null val roomId: String val voiceOnly: Boolean var isFirstMessagesProcessing = true var isLeavingForConversation: Boolean = false var isLinkPreviewAllowed: Boolean = false var wasDetached: Boolean = false var emojiPopup: EmojiPopup? = null var myFirstMessage: CharSequence? = null var checkingLobbyStatus: Boolean = false var conversationInfoMenuItem: MenuItem? = null var conversationVoiceCallMenuItem: MenuItem? = null var conversationVideoMenuItem: MenuItem? = null var magicWebSocketInstance: MagicWebSocketInstance? = null var lobbyTimerHandler: Handler? = null val roomJoined: Boolean = false val imageLoader: coil.ImageLoader by inject() init { setHasOptionsMenu(true) this.conversationUser = args.getParcelable(BundleKeys.KEY_USER_ENTITY) this.roomId = args.getString(BundleKeys.KEY_ROOM_ID, "") this.roomToken = args.getString(BundleKeys.KEY_ROOM_TOKEN, "") if (args.containsKey(BundleKeys.KEY_ACTIVE_CONVERSATION)) { this.currentConversation = Parcels.unwrap( args.getParcelable(BundleKeys.KEY_ACTIVE_CONVERSATION) ) } this.roomPassword = args.getString(BundleKeys.KEY_CONVERSATION_PASSWORD, "") if (conversationUser?.userId == "?") { credentials = null } else { credentials = ApiUtils.getCredentials(conversationUser?.username, conversationUser?.token) } if (args.containsKey(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL)) { this.startCallFromNotification = args.getBoolean(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL) } this.voiceOnly = args.getBoolean(BundleKeys.KEY_CALL_VOICE_ONLY, false) } private fun getRoomInfo() { val shouldRepeat = conversationUser?.hasSpreedFeatureCapability("webinary-lobby") ?: false if (shouldRepeat) { checkingLobbyStatus = true } if (conversationUser != null) { ncApi.getRoom(credentials, ApiUtils.getRoom(conversationUser.baseUrl, roomToken)) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.`as`(AutoDispose.autoDisposable(scopeProvider)) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { } override fun onNext(roomOverall: RoomOverall) { currentConversation = roomOverall.ocs.data loadAvatarForStatusBar() setTitle() setupMentionAutocomplete() checkReadOnlyState() checkLobbyState() if (!inConversation) { joinRoomWithPassword() } } override fun onError(e: Throwable) { } override fun onComplete() { if (shouldRepeat) { if (lobbyTimerHandler == null) { lobbyTimerHandler = Handler() } lobbyTimerHandler?.postDelayed({ getRoomInfo() }, 5000) } } }) } } private fun handleFromNotification() { ncApi.getRooms(credentials, ApiUtils.getUrlForGetRooms(conversationUser?.baseUrl)) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.`as`(AutoDispose.autoDisposable(scopeProvider)) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { } override fun onNext(roomsOverall: RoomsOverall) { for (conversation in roomsOverall.ocs.data) { if (roomId == conversation.conversationId) { roomToken = conversation.token currentConversation = conversation setTitle() getRoomInfo() break } } } override fun onError(e: Throwable) { } override fun onComplete() { } }) } override fun inflateView( inflater: LayoutInflater, container: ViewGroup ): View { return inflater.inflate(R.layout.controller_chat, container, false) } private fun loadAvatarForStatusBar() { if (currentConversation != null && currentConversation?.type != null && currentConversation?.type == Conversation.ConversationType .ONE_TO_ONE_CONVERSATION && activity != null && conversationVoiceCallMenuItem != null ) { val avatarSize = DisplayUtils.convertDpToPixel( conversationVoiceCallMenuItem?.icon!! .intrinsicWidth.toFloat(), activity!! ) .toInt() avatarSize.let { val target = object : Target { override fun onSuccess(result: Drawable) { super.onSuccess(result) actionBar?.setIcon(result) } } // change lifecycle owner once we move to MVVM val avatarRequest = Images().getRequestForUrl( imageLoader, context, ApiUtils.getUrlForAvatarWithNameAndPixels( conversationUser?.baseUrl, currentConversation?.name, avatarSize / 2 ), conversationUser, target, null, CircleCropTransformation() ) imageLoader.load(avatarRequest) } } } override fun onViewBound(view: View) { super.onViewBound(view) actionBar?.show() var adapterWasNull = false if (adapter == null) { loadingProgressBar?.visibility = View.VISIBLE adapterWasNull = true val messageHolders = MessageHolders() messageHolders.setIncomingTextConfig( MagicIncomingTextMessageViewHolder::class.java, R.layout.item_custom_incoming_text_message ) messageHolders.setOutcomingTextConfig( MagicOutcomingTextMessageViewHolder::class.java, R.layout.item_custom_outcoming_text_message ) messageHolders.setIncomingImageConfig( MagicPreviewMessageViewHolder::class.java, R.layout.item_custom_incoming_preview_message ) messageHolders.setOutcomingImageConfig( MagicPreviewMessageViewHolder::class.java, R.layout.item_custom_outcoming_preview_message ) messageHolders.registerContentType( CONTENT_TYPE_SYSTEM_MESSAGE, MagicSystemMessageViewHolder::class.java, R.layout.item_system_message, MagicSystemMessageViewHolder::class.java, R.layout.item_system_message, this ) messageHolders.registerContentType( CONTENT_TYPE_UNREAD_NOTICE_MESSAGE, MagicUnreadNoticeMessageViewHolder::class.java, R.layout.item_date_header, MagicUnreadNoticeMessageViewHolder::class.java, R.layout.item_date_header, this ) adapter = MessagesListAdapter( conversationUser?.userId, messageHolders, ImageLoader { imageView, url, payload -> if (url != "no-preview") { imageView.load(url) { if (conversationUser != null && url!!.startsWith(conversationUser.baseUrl) && (url.contains( "index.php/core/preview?fileId=") || url.contains("/avatar/"))) { addHeader("Authorization", conversationUser.getCredentials()) } if (url!!.contains("/avatar/")) { transformations(CircleCropTransformation()) } else { if (payload is ImageLoaderPayload) { payload.map?.get("mimetype")?.let { val mimeTypeDrawableResource = getDrawableResourceIdForMimeType(it as String) val drawable = context.getDrawable(mimeTypeDrawableResource) placeholder(drawable) error(drawable) } } } } } }) } else { messagesListView?.visibility = View.VISIBLE } messagesListView?.setAdapter(adapter) adapter?.setLoadMoreListener(this) adapter?.setDateHeadersFormatter { format(it) } adapter?.setOnMessageViewLongClickListener { view, message -> onMessageViewLongClick(view, message) } layoutManager = messagesListView?.layoutManager as LinearLayoutManager? popupBubble?.setRecyclerView(messagesListView) popupBubble?.setPopupBubbleListener { context -> if (newMessagesCount != 0) { val scrollPosition: Int if (newMessagesCount - 1 < 0) { scrollPosition = 0 } else { scrollPosition = newMessagesCount - 1 } Handler().postDelayed({ messagesListView?.smoothScrollToPosition(scrollPosition) }, 200) } } messagesListView?.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged( recyclerView: RecyclerView, newState: Int ) { super.onScrollStateChanged(recyclerView, newState) if (newState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE) { if (newMessagesCount != 0 && layoutManager != null) { if (layoutManager!!.findFirstCompletelyVisibleItemPosition() < newMessagesCount ) { newMessagesCount = 0 if (popupBubble != null && popupBubble!!.isShown) { popupBubble?.hide() } } } } } }) val filters = arrayOfNulls(1) val lengthFilter = conversationUser?.getMaxMessageLength() ?: 1000 filters[0] = InputFilter.LengthFilter(lengthFilter) messageInput?.filters = filters messageInput?.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged( s: CharSequence, start: Int, count: Int, after: Int ) { } override fun onTextChanged( s: CharSequence, start: Int, before: Int, count: Int ) { if (s.length >= lengthFilter) { messageInput?.error = String.format( Objects.requireNonNull (resources).getString(R.string.nc_limit_hit), Integer.toString(lengthFilter) ) } else { messageInput?.error = null } val editable = messageInput?.editableText if (editable != null && messageInput != null) { val mentionSpans = editable.getSpans( 0, messageInput!!.length(), Spans.MentionChipSpan::class.java ) var mentionSpan: Spans.MentionChipSpan for (i in mentionSpans.indices) { mentionSpan = mentionSpans[i] if (start >= editable.getSpanStart(mentionSpan) && start < editable.getSpanEnd( mentionSpan ) ) { if (editable.subSequence( editable.getSpanStart(mentionSpan), editable.getSpanEnd(mentionSpan) ).toString().trim { it <= ' ' } != mentionSpan.label ) { editable.removeSpan(mentionSpan) } } } } } override fun afterTextChanged(s: Editable) { } }) messageInputView?.setAttachmentsListener { showBrowserScreen( BrowserController .BrowserType.DAV_BROWSER ) } messageInputView?.button?.setOnClickListener { v -> submitMessage() } messageInputView?.button?.contentDescription = resources?.getString( R.string .nc_description_send_message_button ) if (currentConversation != null && currentConversation?.conversationId != null) { loadAvatarForStatusBar() checkLobbyState() setTitle() } if (adapterWasNull) { // we're starting if (TextUtils.isEmpty(roomToken)) { handleFromNotification() } else { getRoomInfo() } } } private fun checkReadOnlyState() { if (currentConversation != null && conversationUser != null) { if (currentConversation?.shouldShowLobby( conversationUser ) == true || currentConversation?.conversationReadOnlyState != null && currentConversation?.conversationReadOnlyState == Conversation.ConversationReadOnlyState.CONVERSATION_READ_ONLY ) { conversationVoiceCallMenuItem?.icon?.alpha = 99 conversationVideoMenuItem?.icon?.alpha = 99 messageInputView?.visibility = View.GONE } else { if (conversationVoiceCallMenuItem != null) { conversationVoiceCallMenuItem?.icon?.alpha = 255 } if (conversationVideoMenuItem != null) { conversationVideoMenuItem?.icon?.alpha = 255 } if (conversationUser != null && currentConversation != null && currentConversation!! .shouldShowLobby(conversationUser) ) { messageInputView?.visibility = View.GONE } else { messageInputView?.visibility = View.VISIBLE } } } } private fun checkLobbyState() { if (currentConversation != null && conversationUser != null && currentConversation?.isLobbyViewApplicable( conversationUser ) == true ) { if (!checkingLobbyStatus) { getRoomInfo() } if (currentConversation?.shouldShowLobby(conversationUser) == true) { lobbyView?.visibility = View.VISIBLE messagesListView?.visibility = View.GONE messageInputView?.visibility = View.GONE loadingProgressBar?.visibility = View.GONE if (currentConversation?.lobbyTimer != null && currentConversation?.lobbyTimer != 0L ) { conversationLobbyText?.text = String.format( resources!!.getString(R.string.nc_lobby_waiting_with_date), DateUtils.getLocalDateStringFromTimestampForLobby( currentConversation?.lobbyTimer ?: 0 ) ) } else { conversationLobbyText?.setText(R.string.nc_lobby_waiting) } } else { lobbyView?.visibility = View.GONE messagesListView?.visibility = View.VISIBLE messageInput?.visibility = View.VISIBLE } } else { lobbyView?.visibility = View.GONE messagesListView?.visibility = View.VISIBLE messageInput?.visibility = View.VISIBLE } } private fun showBrowserScreen(browserType: BrowserController.BrowserType) { val bundle = Bundle() bundle.putParcelable( BundleKeys.KEY_BROWSER_TYPE, Parcels.wrap(browserType) ) bundle.putParcelable(BundleKeys.KEY_USER_ENTITY, Parcels.wrap(conversationUser)) bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken) router.pushController( RouterTransaction.with(BrowserController(bundle)) .pushChangeHandler(VerticalChangeHandler()) .popChangeHandler(VerticalChangeHandler()) ) } private fun showConversationInfoScreen() { val bundle = Bundle() bundle.putParcelable(BundleKeys.KEY_USER_ENTITY, conversationUser) bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken) router.pushController( RouterTransaction.with(ConversationInfoController(bundle)) .pushChangeHandler(HorizontalChangeHandler()) .popChangeHandler(HorizontalChangeHandler()) ) } private fun setupMentionAutocomplete() { val elevation = 6f val backgroundDrawable = ColorDrawable(resources!!.getColor(R.color.bg_default)) val presenter = MentionAutocompletePresenter(applicationContext!!, roomToken) val callback = MentionAutocompleteCallback( activity, conversationUser, messageInput ) if (mentionAutocomplete == null && messageInput != null) { mentionAutocomplete = Autocomplete.on(messageInput) .with(elevation) .with(backgroundDrawable) .with(MagicCharPolicy('@')) .with(presenter) .with(callback) .build() } } override fun onAttach(view: View) { super.onAttach(view) eventBus.register(this) if (conversationUser?.userId != "?" && conversationUser?.hasSpreedFeatureCapability( "mention-flag" ) == true && activity != null ) { activity?.findViewById(R.id.toolbar) ?.setOnClickListener { v -> showConversationInfoScreen() } } isLeavingForConversation = false isLinkPreviewAllowed = appPreferences.areLinkPreviewsAllowed emojiPopup = messageInput?.let { EmojiPopup.Builder.fromRootView(view) .setOnEmojiPopupShownListener { if (resources != null) { smileyButton?.setColorFilter( resources!!.getColor(R.color.colorPrimary), PorterDuff.Mode.SRC_IN ) } } .setOnEmojiPopupDismissListener { smileyButton?.setColorFilter( resources!!.getColor(R.color.emoji_icons), PorterDuff.Mode.SRC_IN ) } .setOnEmojiClickListener { emoji, imageView -> messageInput?.editableText?.append(" ") } .build(it) } if (activity != null) { KeyboardUtils(activity, getView(), false) } cancelNotificationsForCurrentConversation() if (inConversation) { if (wasDetached && conversationUser?.hasSpreedFeatureCapability("no-ping") == true) { currentConversation?.sessionId = "0" wasDetached = false joinRoomWithPassword() } } } private fun cancelNotificationsForCurrentConversation() { if (conversationUser != null) { if (!conversationUser.hasSpreedFeatureCapability("no-ping") && !TextUtils.isEmpty(roomId)) { NotificationUtils.cancelExistingNotificationsForRoom( applicationContext, conversationUser, roomId ) } else if (!TextUtils.isEmpty(roomToken)) { NotificationUtils.cancelExistingNotificationsForRoom( applicationContext, conversationUser, roomToken!! ) } } } override fun onDetach(view: View) { eventBus.unregister(this) if (activity != null) { activity?.findViewById(R.id.toolbar) ?.setOnClickListener(null) } if (conversationUser != null && conversationUser.hasSpreedFeatureCapability("no-ping") && activity != null && !activity?.isChangingConfigurations!! && !isLeavingForConversation ) { wasDetached = true leaveRoom() } if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { mentionAutocomplete?.dismissPopup() } super.onDetach(view) } override fun getTitle(): String? { if (currentConversation != null && currentConversation?.displayName != null) { return currentConversation!!.displayName?.let { EmojiCompat.get() .process(it) .toString() } } else { return "" } } public override fun onDestroy() { super.onDestroy() adapter = null inConversation = false } private fun startPing() { if (conversationUser != null && !conversationUser.hasSpreedFeatureCapability("no-ping")) { ncApi.pingCall( credentials, ApiUtils.getUrlForCallPing( conversationUser.baseUrl, roomToken ) ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.repeatWhen { observable -> observable.delay(5000, TimeUnit.MILLISECONDS) } ?.takeWhile { observable -> inConversation } ?.retry(3) { observable -> inConversation } ?.`as`(AutoDispose.autoDisposable(scopeProvider)) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { } override fun onNext(genericOverall: GenericOverall) { } override fun onError(e: Throwable) {} override fun onComplete() {} }) } } @OnClick(R.id.smileyButton) internal fun onSmileyClick() { emojiPopup?.toggle() } private fun joinRoomWithPassword() { if (currentConversation == null || TextUtils.isEmpty(currentConversation?.sessionId) || currentConversation?.sessionId == "0" ) { ncApi.joinRoom( credentials, ApiUtils.getUrlForSettingMyselfAsActiveParticipant(conversationUser?.baseUrl, roomToken), roomPassword ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.retry(3) ?.`as`(AutoDispose.autoDisposable(scopeProvider)) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { } override fun onNext(roomOverall: RoomOverall) { inConversation = true currentConversation?.sessionId = roomOverall.ocs.data.sessionId startPing() setupWebsocket() checkLobbyState() if (isFirstMessagesProcessing) { pullChatMessages(0) } else { pullChatMessages(1) } if (magicWebSocketInstance != null) { magicWebSocketInstance?.joinRoomWithRoomTokenAndSession(roomToken!!, currentConversation?.sessionId ) } if (startCallFromNotification != null && startCallFromNotification == true) { startCallFromNotification = false startACall(voiceOnly) } } override fun onError(e: Throwable) { } override fun onComplete() { } }) } else { inConversation = true if (magicWebSocketInstance != null) { magicWebSocketInstance?.joinRoomWithRoomTokenAndSession( roomToken!!, currentConversation?.sessionId ) } startPing() if (isFirstMessagesProcessing) { pullChatMessages(0) } else { pullChatMessages(1) } } } private fun leaveRoom() { ncApi.leaveRoom( credentials, ApiUtils.getUrlForSettingMyselfAsActiveParticipant( conversationUser?.baseUrl, roomToken ) ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { } override fun onNext(genericOverall: GenericOverall) { checkingLobbyStatus = false if (lobbyTimerHandler != null) { lobbyTimerHandler?.removeCallbacksAndMessages(null) } if (magicWebSocketInstance != null && currentConversation != null) { magicWebSocketInstance?.joinRoomWithRoomTokenAndSession( "", currentConversation?.sessionId ) } if (!isDestroyed && !isBeingDestroyed && !wasDetached) { router.popCurrentController() } } override fun onError(e: Throwable) {} override fun onComplete() { } }) } private fun setSenderId() { try { val senderId = adapter?.javaClass?.getDeclaredField("senderId") senderId?.isAccessible = true senderId?.set(adapter, conversationUser?.userId) } catch (e: NoSuchFieldException) { Log.w(TAG, "Failed to set sender id") } catch (e: IllegalAccessException) { Log.w(TAG, "Failed to access and set field") } } private fun submitMessage() { if (messageInput != null) { val editable = messageInput!!.editableText val mentionSpans = editable.getSpans( 0, editable.length, Spans.MentionChipSpan::class.java ) var mentionSpan: Spans.MentionChipSpan for (i in mentionSpans.indices) { mentionSpan = mentionSpans[i] var mentionId = mentionSpan.id if (mentionId.contains(" ") || mentionId.startsWith("guest/")) { mentionId = "\"" + mentionId + "\"" } editable.replace( editable.getSpanStart(mentionSpan), editable.getSpanEnd(mentionSpan), "@$mentionId" ) } messageInput?.setText("") val replyMessageId: Long? = view?.findViewById(R.id.quotedChatMessageView)?.tag as Long? sendMessage(editable, if (view?.findViewById(R.id.quotedChatMessageView)?.visibility == View.VISIBLE) replyMessageId?.toInt() else null) cancelReply() } } private fun sendMessage(message: CharSequence, replyTo: Int?) { if (conversationUser != null) { ncApi.sendChatMessage( credentials, ApiUtils.getUrlForChat( conversationUser.baseUrl, roomToken ), message, conversationUser.displayName, replyTo ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.`as`(AutoDispose.autoDisposable(scopeProvider)) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { } override fun onNext(genericOverall: GenericOverall) { myFirstMessage = message if (popupBubble?.isShown == true) { popupBubble?.hide() } messagesListView?.smoothScrollToPosition(0) } override fun onError(e: Throwable) { if (e is HttpException) { val code = e.code() if (Integer.toString(code).startsWith("2")) { myFirstMessage = message if (popupBubble?.isShown == true) { popupBubble?.hide() } messagesListView?.smoothScrollToPosition(0) } } } override fun onComplete() { } }) } } private fun setupWebsocket() { if (conversationUser != null) { if (WebSocketConnectionHelper.getMagicWebSocketInstanceForUserId( conversationUser.id!! ) != null ) { magicWebSocketInstance = WebSocketConnectionHelper.getMagicWebSocketInstanceForUserId(conversationUser.id!!) } else { magicWebSocketInstance = null } } } private fun pullChatMessages(lookIntoFuture: Int) { if (!inConversation) { return } if (currentConversation != null && conversationUser != null && currentConversation!! .shouldShowLobby(conversationUser) ) { return } val fieldMap = HashMap() fieldMap["includeLastKnown"] = 0 var timeout = 30 if (!lookingIntoFuture) { timeout = 0 } fieldMap["timeout"] = timeout if (lookIntoFuture > 0) { lookingIntoFuture = true } else if (isFirstMessagesProcessing) { if (currentConversation != null) { globalLastKnownFutureMessageId = currentConversation!!.lastReadMessageId globalLastKnownPastMessageId = currentConversation!!.lastReadMessageId fieldMap["includeLastKnown"] = 1 } } fieldMap["lookIntoFuture"] = lookIntoFuture fieldMap["limit"] = 100 fieldMap["setReadMarker"] = 1 val lastKnown: Long if (lookIntoFuture > 0) { lastKnown = globalLastKnownFutureMessageId } else { lastKnown = globalLastKnownPastMessageId } fieldMap["lastKnownMessageId"] = lastKnown.toInt() if (!wasDetached) { if (lookIntoFuture > 0) { val finalTimeout = timeout ncApi.pullChatMessages( credentials, ApiUtils.getUrlForChat(conversationUser?.baseUrl, roomToken), fieldMap ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.takeWhile { observable -> inConversation && !wasDetached } ?.`as`(AutoDispose.autoDisposable(scopeProvider)) ?.subscribe(object : Observer> { override fun onSubscribe(d: Disposable) { } override fun onNext(response: Response<*>) { if (response.code() == 304) { pullChatMessages(1) } else { processMessages(response, true, finalTimeout) } } override fun onError(e: Throwable) { } override fun onComplete() { } }) } else { ncApi.pullChatMessages( credentials, ApiUtils.getUrlForChat(conversationUser?.baseUrl, roomToken), fieldMap ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.retry(3) { observable -> inConversation && !wasDetached } ?.takeWhile { observable -> inConversation && !wasDetached } ?.`as`(AutoDispose.autoDisposable(scopeProvider)) ?.subscribe(object : Observer> { override fun onSubscribe(d: Disposable) { } override fun onNext(response: Response<*>) { processMessages(response, false, 0) } override fun onError(e: Throwable) { } override fun onComplete() { } }) } } } private fun processMessages( response: Response<*>, isFromTheFuture: Boolean, timeout: Int ) { val xChatLastGivenHeader: String? = response.headers() .get("X-Chat-Last-Given") if (response.headers().size() > 0 && !TextUtils.isEmpty(xChatLastGivenHeader)) { val header = xChatLastGivenHeader?.toLong() if (header != null) { if (isFromTheFuture) { globalLastKnownFutureMessageId = header } else { globalLastKnownPastMessageId = header } } } if (response.code() == 200) { val chatOverall = response.body() as ChatOverall? val chatMessageList = chatOverall?.ocs!!.data val wasFirstMessageProcessing = isFirstMessagesProcessing if (isFirstMessagesProcessing) { cancelNotificationsForCurrentConversation() isFirstMessagesProcessing = false loadingProgressBar?.visibility = View.GONE messagesListView?.visibility = View.VISIBLE } var countGroupedMessages = 0 if (!isFromTheFuture) { for (i in chatMessageList.indices) { if (chatMessageList.size > i + 1) { if (TextUtils.isEmpty(chatMessageList[i].systemMessage) && TextUtils.isEmpty(chatMessageList[i + 1].systemMessage) && chatMessageList[i + 1].actorId == chatMessageList[i].actorId && countGroupedMessages < 4 && DateFormatter.isSameDay( chatMessageList[i].createdAt, chatMessageList[i + 1].createdAt ) ) { chatMessageList[i].grouped = true countGroupedMessages++ } else { countGroupedMessages = 0 } } val chatMessage = chatMessageList[i] chatMessage.oneToOneConversation = currentConversation?.type == Conversation.ConversationType.ONE_TO_ONE_CONVERSATION chatMessage.isLinkPreviewAllowed = isLinkPreviewAllowed chatMessage.activeUser = conversationUser } if (wasFirstMessageProcessing && chatMessageList.size > 0) { globalLastKnownFutureMessageId = chatMessageList[0].jsonMessageId } if (adapter != null) { adapter?.addToEnd(chatMessageList, false) } } else { var chatMessage: ChatMessage val shouldAddNewMessagesNotice = timeout == 0 && adapter?.itemCount ?: 0 > 0 && chatMessageList.size > 0 if (shouldAddNewMessagesNotice) { val unreadChatMessage = ChatMessage() unreadChatMessage.jsonMessageId = -1 unreadChatMessage.actorId = "-1" unreadChatMessage.timestamp = chatMessageList[0].timestamp unreadChatMessage.message = context.getString(R.string.nc_new_messages) adapter?.addToStart(unreadChatMessage, false) } val isThereANewNotice = shouldAddNewMessagesNotice || adapter?.getMessagePositionByIdInReverse("-1") != -1 for (i in chatMessageList.indices) { chatMessage = chatMessageList[i] chatMessage.activeUser = conversationUser chatMessage.isLinkPreviewAllowed = isLinkPreviewAllowed // if credentials are empty, we're acting as a guest if (TextUtils.isEmpty(credentials) && myFirstMessage != null && !TextUtils.isEmpty( myFirstMessage?.toString() ) ) { if (chatMessage.actorType == "guests") { conversationUser?.userId = chatMessage.actorId setSenderId() } } val shouldScroll = !isThereANewNotice && !shouldAddNewMessagesNotice && layoutManager?.findFirstVisibleItemPosition() == 0 || adapter != null && adapter?.itemCount == 0 if (!shouldAddNewMessagesNotice && !shouldScroll && popupBubble != null) { if (!popupBubble!!.isShown) { newMessagesCount = 1 popupBubble?.show() } else if (popupBubble!!.isShown) { newMessagesCount++ } } else { newMessagesCount = 0 } if (adapter != null) { chatMessage.grouped = (adapter!!.isPreviousSameAuthor( chatMessage .actorId, -1 ) && adapter!!.getSameAuthorLastMessagesCount(chatMessage.actorId) % 5 > 0) chatMessage.oneToOneConversation = (currentConversation?.type == Conversation.ConversationType.ONE_TO_ONE_CONVERSATION) adapter?.addToStart(chatMessage, shouldScroll) } } if (shouldAddNewMessagesNotice && adapter != null && messagesListView != null) { layoutManager?.scrollToPositionWithOffset( adapter!!.getMessagePositionByIdInReverse("-1"), messagesListView!!.height / 2 ) } } if (inConversation) { pullChatMessages(1) } } else if (response.code() == 304 && !isFromTheFuture) { if (isFirstMessagesProcessing) { cancelNotificationsForCurrentConversation() isFirstMessagesProcessing = false loadingProgressBar?.visibility = View.GONE } historyRead = true if (!lookingIntoFuture && inConversation) { pullChatMessages(1) } } } override fun onLoadMore( page: Int, totalItemsCount: Int ) { if (!historyRead && inConversation) { pullChatMessages(0) } } override fun format(date: Date): String { return if (DateFormatter.isToday(date)) { resources!!.getString(R.string.nc_date_header_today) } else if (DateFormatter.isYesterday(date)) { resources!!.getString(R.string.nc_date_header_yesterday) } else { DateFormatter.format(date, DateFormatter.Template.STRING_DAY_MONTH_YEAR) } } override fun onCreateOptionsMenu( menu: Menu, inflater: MenuInflater ) { super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.menu_conversation, menu) if (conversationUser?.userId == "?") { menu.removeItem(R.id.conversation_info) } else { conversationInfoMenuItem = menu.findItem(R.id.conversation_info) conversationVoiceCallMenuItem = menu.findItem(R.id.conversation_voice_call) conversationVideoMenuItem = menu.findItem(R.id.conversation_video_call) loadAvatarForStatusBar() } } override fun onPrepareOptionsMenu(menu: Menu) { super.onPrepareOptionsMenu(menu) conversationUser?.let { if (it.hasSpreedFeatureCapability("read-only-rooms")) { checkReadOnlyState() } } } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { android.R.id.home -> { router.popCurrentController() return true } R.id.conversation_video_call -> { if (conversationVideoMenuItem?.icon?.alpha == 255) { startACall(false) return true } return false } R.id.conversation_voice_call -> { if (conversationVoiceCallMenuItem?.icon?.alpha == 255) { startACall(true) return true } return false } R.id.conversation_info -> { showConversationInfoScreen() return true } else -> return super.onOptionsItemSelected(item) } } private fun startACall(isVoiceOnlyCall: Boolean) { isLeavingForConversation = true if (!isVoiceOnlyCall) { val videoCallIntent = getIntentForCall(false) if (videoCallIntent != null) { startActivity(videoCallIntent) } } else { val voiceCallIntent = getIntentForCall(true) if (voiceCallIntent != null) { startActivity(voiceCallIntent) } } } private fun getIntentForCall(isVoiceOnlyCall: Boolean): Intent? { if (currentConversation != null) { val bundle = Bundle() bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomToken) bundle.putString(BundleKeys.KEY_ROOM_ID, roomId) bundle.putParcelable(BundleKeys.KEY_USER_ENTITY, conversationUser) bundle.putString(BundleKeys.KEY_CONVERSATION_PASSWORD, roomPassword) bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, conversationUser?.baseUrl) if (isVoiceOnlyCall) { bundle.putBoolean(BundleKeys.KEY_CALL_VOICE_ONLY, true) } if (activity != null) { val callIntent = Intent(activity, MagicCallActivity::class.java) callIntent.putExtras(bundle) return callIntent } else { return null } } else { return null } } @OnClick(R.id.cancelReplyButton) fun cancelReply() { quotedChatMessageView?.visibility = View.GONE messageInputView?.findViewById(R.id.attachmentButton)?.visibility = View.VISIBLE messageInputView?.findViewById(R.id.attachmentButtonSpace)?.visibility = View.VISIBLE } override fun onMessageViewLongClick(view: View?, message: IMessage?) { PopupMenu(this.context, view, if (message?.user?.id == conversationUser?.userId) Gravity.END else Gravity.START).apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { setForceShowIcon(true) } setOnMenuItemClickListener { item -> when (item?.itemId) { R.id.action_copy_message -> { val clipboardManager = activity?.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager val clipData = ClipData.newPlainText(resources?.getString(R.string.nc_app_name), message?.text) clipboardManager.setPrimaryClip(clipData) true } R.id.action_reply_to_message -> { val chatMessage = message as ChatMessage? chatMessage?.let { messageInputView?.findViewById(R.id.attachmentButton)?.visibility = View.GONE messageInputView?.findViewById(R.id.attachmentButtonSpace)?.visibility = View.GONE messageInputView?.findViewById(R.id.cancelReplyButton)?.visibility = View.VISIBLE messageInputView?.findViewById(R.id.quotedMessage)?.maxLines = 2 messageInputView?.findViewById(R.id.quotedMessage)?.ellipsize = TextUtils.TruncateAt.END messageInputView?.findViewById(R.id.quotedMessage)?.text = it.text messageInputView?.findViewById(R.id.quotedMessageTime)?.text = DateFormatter.format(it.createdAt, DateFormatter.Template.TIME) messageInputView?.findViewById(R.id.quotedMessageAuthor)?.text = it.actorDisplayName ?: context.getText(R.string.nc_nick_guest) conversationUser?.let { currentUser -> messageInputView?.findViewById(R.id.quotedUserAvatar)?.load(it.user.avatar) { addHeader("Authorization", currentUser.getCredentials()) transformations(CircleCropTransformation()) } chatMessage.imageUrl?.let { previewImageUrl -> messageInputView?.findViewById(R.id.quotedMessageImage)?.visibility = View.VISIBLE val px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 96f, resources?.displayMetrics) messageInputView?.findViewById(R.id.quotedMessageImage)?.maxHeight = px.toInt() val layoutParams = messageInputView?.findViewById(R.id.quotedMessageImage)?.layoutParams as FlexboxLayout.LayoutParams layoutParams.flexGrow = 0f messageInputView?.findViewById(R.id.quotedMessageImage)?.layoutParams = layoutParams messageInputView?.findViewById(R.id.quotedMessageImage)?.load(previewImageUrl) { addHeader("Authorization", currentUser.getCredentials()) } } ?: run { messageInputView?.findViewById(R.id.quotedMessageImage)?.visibility = View.GONE } } quotedChatMessageView?.tag = message?.jsonMessageId quotedChatMessageView?.visibility = View.VISIBLE } true } else -> false } } inflate(R.menu.chat_message_menu) menu.findItem(R.id.action_reply_to_message).isVisible = (message as ChatMessage).replyable show() } } override fun hasContentFor( message: IMessage, type: Byte ): Boolean { when (type) { CONTENT_TYPE_SYSTEM_MESSAGE -> return !TextUtils.isEmpty(message.systemMessage) CONTENT_TYPE_UNREAD_NOTICE_MESSAGE -> return message.id == "-1" } return false } @Subscribe(threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(webSocketCommunicationEvent: WebSocketCommunicationEvent) { /* switch (webSocketCommunicationEvent.getType()) { case "refreshChat": if (webSocketCommunicationEvent.getHashMap().get(BundleKeys.KEY_INTERNAL_USER_ID).equals(Long.toString(conversationUser.getId()))) { if (roomToken.equals(webSocketCommunicationEvent.getHashMap().get(BundleKeys.KEY_ROOM_TOKEN))) { pullChatMessages(2); } } break; default: }*/ } @Subscribe(threadMode = ThreadMode.BACKGROUND) fun onMessageEvent(userMentionClickEvent: UserMentionClickEvent) { if (currentConversation?.type != Conversation.ConversationType .ONE_TO_ONE_CONVERSATION || currentConversation?.name != userMentionClickEvent.userId ) { val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom( conversationUser?.baseUrl, "1", userMentionClickEvent.userId, null ) ncApi.createRoom( credentials, retrofitBucket.url, retrofitBucket.queryMap ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.`as`(AutoDispose.autoDisposable(scopeProvider)) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { } override fun onNext(roomOverall: RoomOverall) { val conversationIntent = Intent(activity, MagicCallActivity::class.java) val bundle = Bundle() bundle.putParcelable(BundleKeys.KEY_USER_ENTITY, conversationUser) bundle.putString(BundleKeys.KEY_ROOM_TOKEN, roomOverall.ocs.data.token) bundle.putString(BundleKeys.KEY_ROOM_ID, roomOverall.ocs.data.conversationId) if (conversationUser != null) { if (conversationUser.hasSpreedFeatureCapability("chat-v2")) { bundle.putParcelable( BundleKeys.KEY_ACTIVE_CONVERSATION, Parcels.wrap(roomOverall.ocs.data) ) conversationIntent.putExtras(bundle) if (roomOverall != null && roomOverall.ocs != null && roomOverall.ocs.data != null && roomOverall.ocs.data.token != null ) { ConductorRemapping.remapChatController( router, conversationUser.id!!, roomOverall.ocs.data.token!!, bundle, false ) } } } else { conversationIntent.putExtras(bundle) startActivity(conversationIntent) Handler().postDelayed({ if (!isDestroyed && !isBeingDestroyed) { router.popCurrentController() } }, 100) } } override fun onError(e: Throwable) { } override fun onComplete() {} }) } } companion object { private val TAG = "ChatController" val CONTENT_TYPE_SYSTEM_MESSAGE: Byte = 1 val CONTENT_TYPE_UNREAD_NOTICE_MESSAGE: Byte = 2 } }