diff --git a/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt new file mode 100644 index 000000000..cb210bda8 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/controllers/ChatController.kt @@ -0,0 +1,1293 @@ +/* + * 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.Context +import android.content.Intent +import android.content.res.Resources +import android.graphics.Bitmap +import android.graphics.PorterDuff +import android.graphics.drawable.ColorDrawable +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.view.* +import android.widget.* +import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory +import androidx.emoji.text.EmojiCompat +import androidx.emoji.widget.EmojiEditText +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import autodagger.AutoInjector +import butterknife.BindView +import butterknife.OnClick +import com.bluelinelabs.conductor.RouterTransaction +import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler +import com.bluelinelabs.conductor.changehandler.VerticalChangeHandler +import com.facebook.common.executors.UiThreadImmediateExecutorService +import com.facebook.common.references.CloseableReference +import com.facebook.datasource.DataSource +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.imagepipeline.datasource.BaseBitmapDataSubscriber +import com.facebook.imagepipeline.image.CloseableImage +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.application.NextcloudTalkApplication +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.database.UserEntity +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.presenters.MentionAutocompletePresenter +import com.nextcloud.talk.utils.* +import com.nextcloud.talk.utils.bundle.BundleKeys +import com.nextcloud.talk.utils.database.user.UserUtils +import com.nextcloud.talk.utils.preferences.AppPreferences +import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder +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.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.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.parceler.Parcels +import retrofit2.HttpException +import retrofit2.Response +import java.util.* +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@AutoInjector(NextcloudTalkApplication::class) +class ChatController(args: Bundle) : BaseController(args), MessagesListAdapter +.OnLoadMoreListener, MessagesListAdapter.Formatter, MessagesListAdapter +.OnMessageLongClickListener, MessageHolders.ContentChecker { + + @Inject + lateinit internal var ncApi: NcApi + @Inject + lateinit internal var userUtils: UserUtils + @Inject + lateinit internal var appPreferences: AppPreferences + @Inject + lateinit internal var context: Context + @Inject + lateinit internal var eventBus: EventBus + @BindView(R.id.messagesListView) + lateinit internal var messagesListView: MessagesList + @BindView(R.id.messageInputView) + lateinit internal var messageInputView: MessageInput + @BindView(R.id.messageInput) + lateinit internal var messageInput: EmojiEditText + @BindView(R.id.popupBubbleView) + lateinit internal var popupBubble: PopupBubble + @BindView(R.id.progressBar) + lateinit internal var loadingProgressBar: ProgressBar + @BindView(R.id.smileyButton) + lateinit internal var smileyButton: ImageButton + @BindView(R.id.lobby_view) + lateinit internal var lobbyView: RelativeLayout + @BindView(R.id.lobby_text_view) + lateinit internal var conversationLobbyText: TextView + private val disposableList = ArrayList() + private var roomToken: String? = null + private val conversationUser: UserEntity? + private val roomPassword: String + private var credentials: String? = null + private var currentConversation: Conversation? = null + private var inConversation = false + private var historyRead = false + private var globalLastKnownFutureMessageId = -1 + private var globalLastKnownPastMessageId = -1 + private var adapter: MessagesListAdapter? = null + private var mentionAutocomplete: Autocomplete<*>? = null + private var layoutManager: LinearLayoutManager? = null + private var lookingIntoFuture = false + private var newMessagesCount = 0 + private var startCallFromNotification: Boolean? = null + private val roomId: String + private val voiceOnly: Boolean + private var isFirstMessagesProcessing = true + private var isLeavingForConversation: Boolean = false + private var isLinkPreviewAllowed: Boolean = false + private var wasDetached: Boolean = false + private var emojiPopup: EmojiPopup? = null + + private var myFirstMessage: CharSequence? = null + private var checkingLobbyStatus: Boolean = false + + private var conversationInfoMenuItem: MenuItem? = null + private var conversationVoiceCallMenuItem: MenuItem? = null + private var conversationVideoMenuItem: MenuItem? = null + + private var magicWebSocketInstance: MagicWebSocketInstance? = null + + private var lobbyTimerHandler: Handler? = null + private val roomJoined: Boolean = false + + init { + setHasOptionsMenu(true) + NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) + + 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") + if (shouldRepeat) { + checkingLobbyStatus = true + } + + ncApi.getRoom(credentials, ApiUtils.getRoom(conversationUser.baseUrl, roomToken)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + disposableList.add(d) + } + + 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()) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + disposableList.add(d) + } + + override fun onNext(roomsOverall: RoomsOverall) { + for (conversation in roomsOverall.ocs.data) { + if (roomId == conversation.roomId) { + 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 + .ROOM_TYPE_ONE_TO_ONE_CALL && activity != null && conversationVoiceCallMenuItem != null) { + val avatarSize = DisplayUtils.convertDpToPixel(conversationVoiceCallMenuItem!!.icon.intrinsicWidth.toFloat(), activity!!).toInt() + + val imageRequest = DisplayUtils.getImageRequestForUrl(ApiUtils.getUrlForAvatarWithNameAndPixels(conversationUser!!.baseUrl, + currentConversation!!.name, avatarSize / 2), null) + + val imagePipeline = Fresco.getImagePipeline() + val dataSource = imagePipeline.fetchDecodedImage(imageRequest, null) + + dataSource.subscribe(object : BaseBitmapDataSubscriber() { + override fun onNewResultImpl(bitmap: Bitmap?) { + if (actionBar != null && bitmap != null && resources != null) { + val roundedBitmapDrawable = RoundedBitmapDrawableFactory.create(resources!!, bitmap) + roundedBitmapDrawable.isCircular = true + roundedBitmapDrawable.setAntiAlias(true) + actionBar!!.setIcon(roundedBitmapDrawable) + } + } + + override fun onFailureImpl(dataSource: DataSource>) {} + }, UiThreadImmediateExecutorService.getInstance()) + } + } + + 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 -> + val draweeController = Fresco.newDraweeControllerBuilder() + .setImageRequest(DisplayUtils.getImageRequestForUrl(url, conversationUser)) + .setControllerListener(DisplayUtils.getImageControllerListener(imageView)) + .setOldController(imageView.controller) + .setAutoPlayAnimations(true) + .build() + imageView.controller = draweeController + }) + } else { + messagesListView.visibility = View.VISIBLE + } + + messagesListView.setAdapter(adapter) + adapter!!.setLoadMoreListener(this) + adapter!!.setDateHeadersFormatter { format(it) } + adapter!!.setOnMessageLongClickListener { onMessageLongClick(it) } + + 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) { + if (layoutManager!!.findFirstCompletelyVisibleItemPosition() < newMessagesCount) { + newMessagesCount = 0 + + if (popupBubble.isShown) { + popupBubble.hide() + } + } + } + } + } + }) + + + val filters = arrayOfNulls(1) + val lengthFilter = conversationUser!!.messageMaxLength + + + 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 + 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!!.roomId != null) { + loadAvatarForStatusBar() + checkLobbyState() + setTitle() + } + + if (adapterWasNull) { + // we're starting + if (TextUtils.isEmpty(roomToken)) { + handleFromNotification() + } else { + getRoomInfo() + } + } + } + + + private fun checkReadOnlyState() { + if (currentConversation != null) { + if (currentConversation!!.shouldShowLobby(conversationUser) || 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 (currentConversation!!.shouldShowLobby(conversationUser)) { + messageInputView.visibility = View.GONE + } else { + messageInputView.visibility = View.VISIBLE + } + } + } + } + + private fun checkLobbyState() { + if (currentConversation != null && currentConversation!!.isLobbyViewApplicable(conversationUser)) { + + if (!checkingLobbyStatus) { + getRoomInfo() + } + + if (currentConversation!!.shouldShowLobby(conversationUser)) { + 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!!)) + } 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") && activity != null) { + activity!!.findViewById(R.id.toolbar).setOnClickListener { v -> showConversationInfoScreen() } + } + + isLeavingForConversation = false + ApplicationWideCurrentRoomHolder.getInstance().currentRoomId = roomId + ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = roomId + ApplicationWideCurrentRoomHolder.getInstance().isInCall = false + ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser + + isLinkPreviewAllowed = appPreferences!!.areLinkPreviewsAllowed + + emojiPopup = 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(messageInput!!) + + if (activity != null) { + KeyboardUtils(activity!!, getView(), false) + } + + cancelNotificationsForCurrentConversation() + + if (inConversation) { + if (wasDetached && conversationUser.hasSpreedFeatureCapability("no-ping")) { + currentConversation!!.sessionId = "0" + wasDetached = false + joinRoomWithPassword() + } + } + } + + private fun cancelNotificationsForCurrentConversation() { + 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) { + super.onDetach(view) + ApplicationWideCurrentRoomHolder.getInstance().clear() + eventBus!!.unregister(this) + + if (activity != null) { + activity!!.findViewById(R.id.toolbar).setOnClickListener(null) + } + + if (conversationUser!!.hasSpreedFeatureCapability("no-ping") + && activity != null && !activity!!.isChangingConfigurations && !isLeavingForConversation) { + wasDetached = true + leaveRoom() + } + + if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { + mentionAutocomplete!!.dismissPopup() + } + } + + override fun getTitle(): String? { + return EmojiCompat.get().process(currentConversation!!.displayName).toString() + } + + public override fun onDestroy() { + super.onDestroy() + + if (activity != null) { + activity!!.findViewById(R.id.toolbar).setOnClickListener(null) + } + + if (actionBar != null) { + actionBar!!.setIcon(null) + } + + adapter = null + inConversation = false + } + + private fun dispose() { + for (disposable in disposableList) { + if (!disposable.isDisposed()) { + disposable.dispose() + } + } + } + + private fun startPing() { + if (!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 } + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + disposableList.add(d) + } + + 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) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + disposableList.add(d) + } + + override fun onNext(roomOverall: RoomOverall) { + inConversation = true + currentConversation = roomOverall.ocs.data + setTitle() + ApplicationWideCurrentRoomHolder.getInstance().session = + currentConversation!!.sessionId + startPing() + + setupWebsocket() + checkLobbyState() + + if (isFirstMessagesProcessing) { + pullChatMessages(0) + } else { + pullChatMessages(1) + } + + if (magicWebSocketInstance != null) { + magicWebSocketInstance!!.joinRoomWithRoomTokenAndSession(roomToken, currentConversation!!.sessionId) + } + if (startCallFromNotification != null && startCallFromNotification!!) { + startCallFromNotification = false + startACall(voiceOnly) + } + } + + override fun onError(e: Throwable) { + + } + + override fun onComplete() { + + } + }) + } else { + inConversation = true + ApplicationWideCurrentRoomHolder.getInstance().session = currentConversation!!.sessionId + 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) { + disposableList.add(d) + } + + 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() { + dispose() + } + }) + } + + 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() { + 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("") + sendMessage(editable) + } + + private fun sendMessage(message: CharSequence) { + + ncApi!!.sendChatMessage(credentials, ApiUtils.getUrlForChat(conversationUser!!.baseUrl, roomToken), + message, conversationUser.displayName) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + + } + + override fun onNext(genericOverall: GenericOverall) { + myFirstMessage = message + + if (popupBubble != null && popupBubble!!.isShown) { + popupBubble!!.hide() + } + + if (messagesListView != null) { + 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 != null && popupBubble!!.isShown) { + popupBubble!!.hide() + } + + messagesListView!!.smoothScrollToPosition(0) + } + } + } + + override fun onComplete() { + + } + }) + } + + private fun setupWebsocket() { + if (WebSocketConnectionHelper.getMagicWebSocketInstanceForUserId(conversationUser!!.id) != null) { + magicWebSocketInstance = WebSocketConnectionHelper.getMagicWebSocketInstanceForUserId(conversationUser.id) + } else { + magicWebSocketInstance = null + } + } + + private fun pullChatMessages(lookIntoFuture: Int) { + if (!inConversation) { + return + } + + if (currentConversation!!.shouldShowLobby(conversationUser)) { + return + } + + val fieldMap = HashMap() + fieldMap["includeLastKnown"] = 0 + + var timeout = 30 + if (lookIntoFuture == 0) { + timeout = 0 + } + + fieldMap["timeout"] = timeout + + if (lookIntoFuture > 0) { + lookingIntoFuture = true + } else if (isFirstMessagesProcessing) { + globalLastKnownFutureMessageId = currentConversation!!.lastReadMessage + globalLastKnownPastMessageId = currentConversation!!.lastReadMessage + fieldMap["includeLastKnown"] = 1 + } + + fieldMap["lookIntoFuture"] = lookIntoFuture + fieldMap["limit"] = 100 + fieldMap["setReadMarker"] = 1 + + val lastKnown: Int + if (lookIntoFuture > 0) { + lastKnown = globalLastKnownFutureMessageId + } else { + lastKnown = globalLastKnownPastMessageId + } + + fieldMap["lastKnownMessageId"] = lastKnown + + 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 } + .subscribe(object : Observer> { + override fun onSubscribe(d: Disposable) { + disposableList.add(d) + } + + override fun onNext(response: Response<*>) { + 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 } + .subscribe(object : Observer> { + override fun onSubscribe(d: Disposable) { + disposableList.add(d) + } + + 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 = Integer.parseInt(xChatLastGivenHeader!!) + if (header > 0) { + 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].isGrouped = true; + countGroupedMessages++ + } else { + countGroupedMessages = 0 + } + } + + val chatMessage = chatMessageList[i] + chatMessage.isOneToOneConversation = currentConversation!!.type == Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + chatMessage.isLinkPreviewAllowed = isLinkPreviewAllowed + chatMessage.activeUser = conversationUser + + } + + if (isFirstMessagesProcessing) { + globalLastKnownFutureMessageId = chatMessageList[0].jsonMessageId + } + + if (adapter != null) { + adapter!!.addToEnd(chatMessageList, false) + } + + } else { + + var chatMessage: ChatMessage + + val shouldAddNewMessagesNotice = timeout == 0 && adapter!!.itemCount > 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) { + if (!popupBubble.isShown) { + newMessagesCount = 1 + popupBubble.show() + } else if (popupBubble.isShown) { + newMessagesCount++ + } + } else { + newMessagesCount = 0 + } + + if (adapter != null) { + chatMessage.isGrouped = (adapter!!.isPreviousSameAuthor(chatMessage.actorId, -1) && adapter!!.getSameAuthorLastMessagesCount(chatMessage.actorId) % 5 > 0) + chatMessage.isOneToOneConversation = (currentConversation!!.type == Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) + adapter!!.addToStart(chatMessage, shouldScroll) + } + + } + + if (shouldAddNewMessagesNotice && adapter != 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) + if (conversationUser!!.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 + } + } + + override fun onMessageLongClick(message: IMessage) { + if (activity != null) { + val clipboardManager = activity!!.getSystemService(Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager + val clipData = android.content.ClipData.newPlainText( + resources!!.getString(R.string.nc_app_name), message.text) + if (clipboardManager != null) { + clipboardManager.primaryClip = clipData + } + } + } + + 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 + .ROOM_TYPE_ONE_TO_ONE_CALL || 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()) + .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.roomId) + + if (conversationUser.hasSpreedFeatureCapability("chat-v2")) { + bundle.putParcelable(BundleKeys.KEY_ACTIVE_CONVERSATION, + Parcels.wrap(roomOverall.ocs.data)) + conversationIntent.putExtras(bundle) + + 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" + private val CONTENT_TYPE_SYSTEM_MESSAGE: Byte = 1 + private val CONTENT_TYPE_UNREAD_NOTICE_MESSAGE: Byte = 2 + } +} diff --git a/app/src/main/java/com/nextcloud/talk/events/UserMentionClickEvent.java b/app/src/main/java/com/nextcloud/talk/events/UserMentionClickEvent.java index ee88a4c9d..351fa39e0 100644 --- a/app/src/main/java/com/nextcloud/talk/events/UserMentionClickEvent.java +++ b/app/src/main/java/com/nextcloud/talk/events/UserMentionClickEvent.java @@ -24,5 +24,5 @@ import lombok.Data; @Data public class UserMentionClickEvent { - private final String userId; + public final String userId; } diff --git a/app/src/main/java/com/nextcloud/talk/models/RetrofitBucket.java b/app/src/main/java/com/nextcloud/talk/models/RetrofitBucket.java index 63c3c6659..8ea7a07e8 100644 --- a/app/src/main/java/com/nextcloud/talk/models/RetrofitBucket.java +++ b/app/src/main/java/com/nextcloud/talk/models/RetrofitBucket.java @@ -27,6 +27,6 @@ import java.util.Map; @Parcel @Data public class RetrofitBucket { - String url; - Map queryMap; + public String url; + public Map queryMap; } diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java index 9fd2c6293..798974d10 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java +++ b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatMessage.java @@ -56,26 +56,26 @@ public class ChatMessage implements IMessage, MessageContentType, MessageContent MessageType.SYSTEM_MESSAGE, MessageType.SINGLE_LINK_VIDEO_MESSAGE, MessageType.SINGLE_LINK_AUDIO_MESSAGE, MessageType.SINGLE_LINK_MESSAGE); @JsonField(name = "id") - int jsonMessageId; + public int jsonMessageId; @JsonField(name = "token") - String token; + public String token; // guests or users @JsonField(name = "actorType") - String actorType; + public String actorType; @JsonField(name = "actorId") - String actorId; + public String actorId; // send when crafting a message @JsonField(name = "actorDisplayName") - String actorDisplayName; + public String actorDisplayName; @JsonField(name = "timestamp") - long timestamp; + public long timestamp; // send when crafting a message, max 1000 lines @JsonField(name = "message") - String message; + public String message; @JsonField(name = "messageParameters") - HashMap> messageParameters; + public HashMap> messageParameters; @JsonField(name = "systemMessage", typeConverter = EnumSystemMessageTypeConverter.class) - SystemMessageType systemMessageType; + public SystemMessageType systemMessageType; private boolean hasFileAttachment() { if (messageParameters != null && messageParameters.size() > 0) { diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatOCS.java b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatOCS.java index 7945e3e4d..b5cf0cd7d 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatOCS.java +++ b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatOCS.java @@ -32,5 +32,5 @@ import java.util.List; @JsonObject public class ChatOCS extends GenericOCS { @JsonField(name = "data") - List data; + public List data; } diff --git a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatOverall.java b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatOverall.java index a8c088245..b89831f76 100644 --- a/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatOverall.java +++ b/app/src/main/java/com/nextcloud/talk/models/json/chat/ChatOverall.java @@ -30,5 +30,5 @@ import org.parceler.Parcel; @JsonObject public class ChatOverall { @JsonField(name = "ocs") - ChatOCS ocs; + public ChatOCS ocs; } diff --git a/app/src/main/java/com/nextcloud/talk/utils/text/Spans.java b/app/src/main/java/com/nextcloud/talk/utils/text/Spans.java index faa6f9733..7fea458e6 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/text/Spans.java +++ b/app/src/main/java/com/nextcloud/talk/utils/text/Spans.java @@ -29,8 +29,8 @@ public class Spans { @Data public static class MentionChipSpan extends BetterImageSpan { - String id; - CharSequence label; + public String id; + public CharSequence label; public MentionChipSpan(@NonNull Drawable drawable, int verticalAlignment, String id, CharSequence label) { super(drawable, verticalAlignment);