/* * Nextcloud Talk application * * @author Mario Danic * @author Marcel Hibbe * @author Andy Scherzinger * @author Tim Krüger * Copyright (C) 2021 Tim Krüger * Copyright (C) 2021 Andy Scherzinger * Copyright (C) 2021 Marcel Hibbe * 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.Manifest import android.annotation.SuppressLint import android.app.Activity.RESULT_OK import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.res.AssetFileDescriptor import android.content.res.ColorStateList import android.content.res.Resources import android.database.Cursor import android.graphics.Bitmap import android.graphics.Color import android.graphics.drawable.ColorDrawable import android.media.MediaPlayer import android.media.MediaRecorder import android.net.Uri import android.os.Build import android.os.Build.VERSION_CODES.O import android.os.Bundle import android.os.Handler import android.os.Parcelable import android.os.SystemClock import android.os.VibrationEffect import android.os.Vibrator import android.provider.ContactsContract 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.Gravity import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.MotionEvent import android.view.View import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.LinearInterpolator import android.widget.AbsListView import android.widget.ImageButton import android.widget.ImageView import android.widget.PopupMenu import android.widget.RelativeLayout import android.widget.Toast import androidx.appcompat.view.ContextThemeWrapper import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.content.PermissionChecker import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory import androidx.core.widget.doAfterTextChanged import androidx.emoji.text.EmojiCompat import androidx.emoji.widget.EmojiTextView import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.work.Data import androidx.work.OneTimeWorkRequest import androidx.work.WorkInfo import androidx.work.WorkManager import autodagger.AutoInjector import coil.load import com.bluelinelabs.conductor.RouterTransaction import com.bluelinelabs.conductor.changehandler.HorizontalChangeHandler 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.google.android.flexbox.FlexboxLayout import com.nextcloud.talk.BuildConfig import com.nextcloud.talk.R import com.nextcloud.talk.activities.CallActivity import com.nextcloud.talk.activities.MainActivity import com.nextcloud.talk.activities.TakePhotoActivity import com.nextcloud.talk.adapters.messages.IncomingLocationMessageViewHolder import com.nextcloud.talk.adapters.messages.IncomingPollMessageViewHolder import com.nextcloud.talk.adapters.messages.IncomingPreviewMessageViewHolder import com.nextcloud.talk.adapters.messages.IncomingVoiceMessageViewHolder import com.nextcloud.talk.adapters.messages.MagicIncomingTextMessageViewHolder import com.nextcloud.talk.adapters.messages.MagicOutcomingTextMessageViewHolder import com.nextcloud.talk.adapters.messages.MagicSystemMessageViewHolder import com.nextcloud.talk.adapters.messages.MagicUnreadNoticeMessageViewHolder import com.nextcloud.talk.adapters.messages.MessagePayload import com.nextcloud.talk.adapters.messages.OutcomingLocationMessageViewHolder import com.nextcloud.talk.adapters.messages.OutcomingPollMessageViewHolder import com.nextcloud.talk.adapters.messages.OutcomingPreviewMessageViewHolder import com.nextcloud.talk.adapters.messages.OutcomingVoiceMessageViewHolder import com.nextcloud.talk.adapters.messages.PreviewMessageInterface import com.nextcloud.talk.adapters.messages.ReactionsInterface import com.nextcloud.talk.adapters.messages.TalkMessagesListAdapter import com.nextcloud.talk.adapters.messages.VoiceMessageInterface import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.callbacks.MentionAutocompleteCallback import com.nextcloud.talk.controllers.base.NewBaseController import com.nextcloud.talk.controllers.util.viewBinding import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.databinding.ControllerChatBinding import com.nextcloud.talk.events.UserMentionClickEvent import com.nextcloud.talk.events.WebSocketCommunicationEvent import com.nextcloud.talk.jobs.DownloadFileToCacheWorker import com.nextcloud.talk.jobs.ShareOperationWorker import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.messagesearch.MessageSearchActivity import com.nextcloud.talk.models.json.chat.ChatMessage import com.nextcloud.talk.models.json.chat.ChatOverall import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage import com.nextcloud.talk.models.json.chat.ReadStatus 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.polls.ui.PollCreateDialogFragment import com.nextcloud.talk.presenters.MentionAutocompletePresenter import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity import com.nextcloud.talk.shareditems.activities.SharedItemsActivity import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet import com.nextcloud.talk.ui.dialog.AttachmentDialog import com.nextcloud.talk.ui.dialog.MessageActionsDialog import com.nextcloud.talk.ui.dialog.ShowReactionsDialog import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback import com.nextcloud.talk.ui.theme.ServerTheme import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.AttendeePermissionsUtil import com.nextcloud.talk.utils.ConductorRemapping import com.nextcloud.talk.utils.ConductorRemapping.remapChatController import com.nextcloud.talk.utils.ContactUtils import com.nextcloud.talk.utils.DateUtils import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.ImageEmojiEditText import com.nextcloud.talk.utils.MagicCharPolicy import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.UriUtils import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACTIVE_CONVERSATION import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_PATHS import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_USER_ENTITY import com.nextcloud.talk.utils.database.user.CapabilitiesUtilNew import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.rx.DisposableSet 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.MessageHolders.ContentChecker import com.stfalcon.chatkit.messages.MessagesListAdapter import com.stfalcon.chatkit.utils.DateFormatter import com.vanniktech.emoji.EmojiPopup import com.yarolegovich.lovelydialog.LovelyStandardDialog import io.reactivex.Observer import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import kotlinx.android.synthetic.main.view_message_input.view.* 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.io.File import java.io.IOException import java.net.HttpURLConnection import java.text.SimpleDateFormat import java.util.Date import java.util.Objects import java.util.concurrent.ExecutionException import javax.inject.Inject import kotlin.math.roundToInt @AutoInjector(NextcloudTalkApplication::class) class ChatController(args: Bundle) : NewBaseController( R.layout.controller_chat, args ), MessagesListAdapter.OnLoadMoreListener, MessagesListAdapter.Formatter, MessagesListAdapter.OnMessageViewLongClickListener, ContentChecker, VoiceMessageInterface, ReactionsInterface, PreviewMessageInterface { private val binding: ControllerChatBinding by viewBinding(ControllerChatBinding::bind) @Inject lateinit var ncApi: NcApi @Inject lateinit var eventBus: EventBus @Inject lateinit var permissionUtil: PlatformPermissionUtil @Inject lateinit var serverTheme: ServerTheme val disposables = DisposableSet() var roomToken: String? = null val conversationUser: User? val roomPassword: String var credentials: String? = null var currentConversation: Conversation? = null var inConversation = false var historyRead = false var globalLastKnownFutureMessageId = -1 var globalLastKnownPastMessageId = -1 var adapter: TalkMessagesListAdapter? = null var mentionAutocomplete: Autocomplete<*>? = null var layoutManager: LinearLayoutManager? = null var pullChatMessagesPending = false var lookingIntoFuture = false var newMessagesCount = 0 var startCallFromNotification: Boolean? = null val roomId: String val voiceOnly: Boolean var isFirstMessagesProcessing = true 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 conversationSharedItemsItem: MenuItem? = null var magicWebSocketInstance: MagicWebSocketInstance? = null var lobbyTimerHandler: Handler? = null var pastPreconditionFailed = false var futurePreconditionFailed = false val filesToUpload: MutableList = ArrayList() var sharedText: String var isVoiceRecordingInProgress: Boolean = false var currentVoiceRecordFile: String = "" private var recorder: MediaRecorder? = null var mediaPlayer: MediaPlayer? = null lateinit var mediaPlayerHandler: Handler var currentlyPlayedVoiceMessage: ChatMessage? = null var hasChatPermission: Boolean = false init { Log.d(TAG, "init ChatController: " + System.identityHashCode(this).toString()) setHasOptionsMenu(true) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) this.conversationUser = args.getParcelable(KEY_USER_ENTITY) this.roomId = args.getString(KEY_ROOM_ID, "") this.roomToken = args.getString(KEY_ROOM_TOKEN, "") this.sharedText = args.getString(BundleKeys.KEY_SHARED_TEXT, "") Log.d(TAG, " roomToken = $roomToken") if (roomToken.isNullOrEmpty()) { Log.d(TAG, " roomToken was null or empty!") } if (args.containsKey(KEY_ACTIVE_CONVERSATION)) { this.currentConversation = Parcels.unwrap(args.getParcelable(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 = CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "webinary-lobby") if (shouldRepeat) { checkingLobbyStatus = true } if (conversationUser != null) { val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.APIv4, 1)) val startNanoTime = System.nanoTime() Log.d(TAG, "getRoomInfo - getRoom - calling: " + startNanoTime) ncApi.getRoom(credentials, ApiUtils.getUrlForRoom(apiVersion, conversationUser.baseUrl, roomToken)) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { disposables.add(d) } @Suppress("Detekt.TooGenericExceptionCaught") override fun onNext(roomOverall: RoomOverall) { Log.d(TAG, "getRoomInfo - getRoom - got response: " + startNanoTime) currentConversation = roomOverall.ocs!!.data Log.d( TAG, "getRoomInfo. token: " + currentConversation?.token + " sessionId: " + currentConversation?.sessionId ) loadAvatarForStatusBar() setTitle() hasChatPermission = AttendeePermissionsUtil(currentConversation!!.permissions).hasChatPermission( conversationUser ) try { setupMentionAutocomplete() checkShowCallButtons() checkShowMessageInputView() checkLobbyState() if (!inConversation) { joinRoomWithPassword() } } catch (npe: NullPointerException) { // view binding can be null // since this is called asynchrously and UI might have been destroyed in the meantime Log.i(TAG, "UI destroyed - view binding already gone") } } override fun onError(e: Throwable) { Log.e(TAG, "getRoomInfo - getRoom - ERROR", e) } override fun onComplete() { Log.d(TAG, "getRoomInfo - getRoom - onComplete: " + startNanoTime) if (shouldRepeat) { if (lobbyTimerHandler == null) { lobbyTimerHandler = Handler() } lobbyTimerHandler?.postDelayed({ getRoomInfo() }, LOBBY_TIMER_DELAY) } } }) } } private fun handleFromNotification() { var apiVersion = 1 // FIXME Can this be called for guests? if (conversationUser != null) { apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.APIv4, 1)) } Log.d(TAG, "handleFromNotification - getRooms - calling") ncApi.getRooms(credentials, ApiUtils.getUrlForRooms(apiVersion, conversationUser?.baseUrl)) ?.subscribeOn(Schedulers.io())?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { disposables.add(d) } override fun onNext(roomsOverall: RoomsOverall) { Log.d(TAG, "handleFromNotification - getRooms - got response") for (conversation in roomsOverall.ocs!!.data!!) { if (roomId == conversation.roomId) { roomToken = conversation.token currentConversation = conversation setTitle() getRoomInfo() break } } } override fun onError(e: Throwable) { Log.e(TAG, "handleFromNotification - getRooms - ERROR: ", e) } override fun onComplete() { // unused atm } }) } private fun loadAvatarForStatusBar() { if (inOneToOneCall() && activity != null) { val imageRequest = DisplayUtils.getImageRequestForUrl( ApiUtils.getUrlForAvatar( conversationUser?.baseUrl, currentConversation?.name, true ), conversationUser!! ) 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 avatarSize = (actionBar?.height!! / TOOLBAR_AVATAR_RATIO).roundToInt() if (avatarSize > 0) { val bitmapResized = Bitmap.createScaledBitmap(bitmap, avatarSize, avatarSize, false) val roundedBitmapDrawable = RoundedBitmapDrawableFactory.create(resources!!, bitmapResized) roundedBitmapDrawable.isCircular = true roundedBitmapDrawable.setAntiAlias(true) actionBar?.setIcon(roundedBitmapDrawable) } else { Log.d(TAG, "loadAvatarForStatusBar avatarSize <= 0") } } } override fun onFailureImpl(dataSource: DataSource>) { // unused atm } }, UiThreadImmediateExecutorService.getInstance() ) } } private fun inOneToOneCall() = currentConversation != null && currentConversation?.type != null && currentConversation?.type == Conversation.ConversationType .ROOM_TYPE_ONE_TO_ONE_CALL @Suppress("Detekt.TooGenericExceptionCaught") override fun onViewBound(view: View) { Log.d(TAG, "onViewBound: " + System.identityHashCode(this).toString()) actionBar?.show() var adapterWasNull = false if (adapter == null) { binding.progressBar.visibility = View.VISIBLE adapterWasNull = true val messageHolders = MessageHolders() val profileBottomSheet = ProfileBottomSheet(ncApi, conversationUser!!, router) val payload = MessagePayload(roomToken!!, currentConversation?.isParticipantOwnerOrModerator, profileBottomSheet) messageHolders.setIncomingTextConfig( MagicIncomingTextMessageViewHolder::class.java, R.layout.item_custom_incoming_text_message, payload ) messageHolders.setOutcomingTextConfig( MagicOutcomingTextMessageViewHolder::class.java, R.layout.item_custom_outcoming_text_message ) messageHolders.setIncomingImageConfig( IncomingPreviewMessageViewHolder::class.java, R.layout.item_custom_incoming_preview_message, payload ) messageHolders.setOutcomingImageConfig( OutcomingPreviewMessageViewHolder::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 ) messageHolders.registerContentType( CONTENT_TYPE_LOCATION, IncomingLocationMessageViewHolder::class.java, payload, R.layout.item_custom_incoming_location_message, OutcomingLocationMessageViewHolder::class.java, null, R.layout.item_custom_outcoming_location_message, this ) messageHolders.registerContentType( CONTENT_TYPE_VOICE_MESSAGE, IncomingVoiceMessageViewHolder::class.java, payload, R.layout.item_custom_incoming_voice_message, OutcomingVoiceMessageViewHolder::class.java, null, R.layout.item_custom_outcoming_voice_message, this ) messageHolders.registerContentType( CONTENT_TYPE_POLL, IncomingPollMessageViewHolder::class.java, payload, R.layout.item_custom_incoming_poll_message, OutcomingPollMessageViewHolder::class.java, payload, R.layout.item_custom_outcoming_poll_message, this ) var senderId = "" if (!conversationUser?.userId.equals("?")) { senderId = "users/" + conversationUser?.userId } else { senderId = currentConversation?.actorType + "/" + currentConversation?.actorId } Log.d(TAG, "Initialize TalkMessagesListAdapter with senderId: " + senderId) adapter = TalkMessagesListAdapter( senderId, 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 }, this ) } else { binding.messagesListView.visibility = View.VISIBLE } binding.messagesListView.setAdapter(adapter) adapter?.setLoadMoreListener(this) adapter?.setDateHeadersFormatter { format(it) } adapter?.setOnMessageViewLongClickListener { view, message -> onMessageViewLongClick(view, message) } adapter?.registerViewClickListener( R.id.playPauseBtn ) { view, message -> val filename = message.selectedIndividualHashMap!!["name"] val file = File(context!!.cacheDir, filename!!) if (file.exists()) { if (message.isPlayingVoiceMessage) { pausePlayback(message) } else { startPlayback(message) } } else { downloadFileToCache(message) } } if (context != null && hasChatPermission && !isReadOnlyConversation()) { val messageSwipeController = MessageSwipeCallback( activity!!, object : MessageSwipeActions { override fun showReplyUI(position: Int) { val chatMessage = adapter?.items?.get(position)?.item as ChatMessage? replyToMessage(chatMessage) } } ) val itemTouchHelper = ItemTouchHelper(messageSwipeController) itemTouchHelper.attachToRecyclerView(binding.messagesListView) } layoutManager = binding.messagesListView.layoutManager as LinearLayoutManager? binding.popupBubbleView.setRecyclerView(binding.messagesListView) binding.popupBubbleView.setPopupBubbleListener { context -> if (newMessagesCount != 0) { val scrollPosition: Int if (newMessagesCount - 1 < 0) { scrollPosition = 0 } else { scrollPosition = newMessagesCount - 1 } Handler().postDelayed( { binding.messagesListView.smoothScrollToPosition(scrollPosition) }, NEW_MESSAGES_POPUP_BUBBLE_DELAY ) } } viewThemeUtils.colorMaterialButtonPrimaryFilled(binding.popupBubbleView) binding.messageInputView.setPadding(0, 0, 0, 0) binding.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 (binding.popupBubbleView.isShown) { binding.popupBubbleView.hide() } } } } } }) val filters = arrayOfNulls(1) val lengthFilter = CapabilitiesUtilNew.getMessageMaxLength(conversationUser) ?: MESSAGE_MAX_LENGTH filters[0] = InputFilter.LengthFilter(lengthFilter) binding.messageInputView.inputEditText?.filters = filters binding.messageInputView.inputEditText?.addTextChangedListener(object : TextWatcher { override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { // unused atm } @Suppress("Detekt.TooGenericExceptionCaught") override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { try { if (s.length >= lengthFilter) { binding.messageInputView.inputEditText?.error = String.format( Objects.requireNonNull(resources).getString(R.string.nc_limit_hit), Integer.toString(lengthFilter) ) } else { binding.messageInputView.inputEditText?.error = null } val editable = binding.messageInputView.inputEditText?.editableText if (editable != null && binding.messageInputView.inputEditText != null) { val mentionSpans = editable.getSpans( 0, binding.messageInputView.inputEditText!!.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) } } } } } catch (npe: NullPointerException) { // view binding can be null // since this is called asynchronously and UI might have been destroyed in the meantime Log.i(TAG, "UI destroyed - view binding already gone") } } override fun afterTextChanged(s: Editable) { // unused atm } }) // Image keyboard support // See: https://developer.android.com/guide/topics/text/image-keyboard (binding.messageInputView.inputEditText as ImageEmojiEditText).onCommitContentListener = { uploadFiles(mutableListOf(it.toString()), false) } showMicrophoneButton(true) binding.messageInputView.messageInput.doAfterTextChanged { try { if (binding.messageInputView.messageInput.text.isEmpty()) { showMicrophoneButton(true) } else { showMicrophoneButton(false) } } catch (npe: NullPointerException) { // view binding can be null // since this is called asynchronously and UI might have been destroyed in the meantime Log.i(TAG, "UI destroyed - view binding already gone") } } var sliderInitX = 0F var downX = 0f var deltaX = 0f var voiceRecordStartTime = 0L var voiceRecordEndTime = 0L binding.messageInputView.recordAudioButton.setOnTouchListener(object : View.OnTouchListener { override fun onTouch(v: View?, event: MotionEvent?): Boolean { view.performClick() when (event?.action) { MotionEvent.ACTION_DOWN -> { if (!isRecordAudioPermissionGranted()) { requestRecordAudioPermissions() return true } if (!UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) { UploadAndShareFilesWorker.requestStoragePermission(this@ChatController) return true } voiceRecordStartTime = System.currentTimeMillis() setVoiceRecordFileName() startAudioRecording(currentVoiceRecordFile) downX = event.x showRecordAudioUi(true) } MotionEvent.ACTION_CANCEL -> { Log.d(TAG, "ACTION_CANCEL. same as for UP") if (!isVoiceRecordingInProgress || !isRecordAudioPermissionGranted()) { return true } stopAndDiscardAudioRecording() showRecordAudioUi(false) binding.messageInputView.slideToCancelDescription.x = sliderInitX } MotionEvent.ACTION_UP -> { Log.d(TAG, "ACTION_UP. stop recording??") if (!isVoiceRecordingInProgress || !isRecordAudioPermissionGranted()) { return true } showRecordAudioUi(false) voiceRecordEndTime = System.currentTimeMillis() val voiceRecordDuration = voiceRecordEndTime - voiceRecordStartTime if (voiceRecordDuration < MINIMUM_VOICE_RECORD_DURATION) { Log.d(TAG, "voiceRecordDuration: " + voiceRecordDuration) Toast.makeText( context, context!!.getString(R.string.nc_voice_message_hold_to_record_info), Toast.LENGTH_SHORT ).show() stopAndDiscardAudioRecording() return true } else { voiceRecordStartTime = 0L voiceRecordEndTime = 0L stopAndSendAudioRecording() } binding.messageInputView.slideToCancelDescription.x = sliderInitX } MotionEvent.ACTION_MOVE -> { Log.d(TAG, "ACTION_MOVE.") if (!isVoiceRecordingInProgress || !isRecordAudioPermissionGranted()) { return true } showRecordAudioUi(true) if (sliderInitX == 0.0F) { sliderInitX = binding.messageInputView.slideToCancelDescription.x } val movedX: Float = event.x deltaX = movedX - downX // only allow slide to left if (binding.messageInputView.slideToCancelDescription.x > sliderInitX) { binding.messageInputView.slideToCancelDescription.x = sliderInitX } if (binding.messageInputView.slideToCancelDescription.x < VOICE_RECORD_CANCEL_SLIDER_X) { Log.d(TAG, "stopping recording because slider was moved to left") stopAndDiscardAudioRecording() showRecordAudioUi(false) binding.messageInputView.slideToCancelDescription.x = sliderInitX return true } else { binding.messageInputView.slideToCancelDescription.x = binding.messageInputView .slideToCancelDescription.x + deltaX downX = movedX } } } return v?.onTouchEvent(event) ?: true } }) binding.messageInputView.inputEditText?.setText(sharedText) binding.messageInputView.setAttachmentsListener { activity?.let { AttachmentDialog(it, this).show() } } binding.messageInputView.button.setOnClickListener { submitMessage(false) } if (CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "silent-send")) { binding.messageInputView.button.setOnLongClickListener { showSendButtonMenu() true } } binding.messageInputView.button.contentDescription = resources?.getString( R.string .nc_description_send_message_button ) viewThemeUtils.colorImageView(binding.messageInputView.button) if (currentConversation != null && currentConversation?.roomId != null) { loadAvatarForStatusBar() setTitle() } if (adapterWasNull) { // we're starting if (TextUtils.isEmpty(roomToken)) { handleFromNotification() } else { getRoomInfo() } } super.onViewBound(view) } private fun showSendButtonMenu() { val popupMenu = PopupMenu( ContextThemeWrapper(view?.context, R.style.ChatSendButtonMenu), binding.messageInputView.button, Gravity.END ) popupMenu.inflate(R.menu.chat_send_menu) popupMenu.setOnMenuItemClickListener { item: MenuItem -> when (item.itemId) { R.id.send_without_notification -> submitMessage(true) } true } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { popupMenu.setForceShowIcon(true) } popupMenu.show() } private fun showCallButtonMenu(isVoiceOnlyCall: Boolean) { val anchor: View? = if (isVoiceOnlyCall) { activity?.findViewById(R.id.conversation_voice_call) } else { activity?.findViewById(R.id.conversation_video_call) } if (anchor != null) { val popupMenu = PopupMenu( ContextThemeWrapper(view?.context, R.style.CallButtonMenu), anchor, Gravity.END ) popupMenu.inflate(R.menu.chat_call_menu) popupMenu.setOnMenuItemClickListener { item: MenuItem -> when (item.itemId) { R.id.call_without_notification -> startACall(isVoiceOnlyCall, true) } true } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { popupMenu.setForceShowIcon(true) } popupMenu.show() } } private fun startPlayback(message: ChatMessage) { if (!this.isAttached) { // don't begin to play voice message if screen is not visible anymore. // this situation might happen if file is downloading but user already left the chatview. // If user returns to chatview, the old chatview instance is not attached anymore // and he has to click the play button again (which is considered to be okay) return } initMediaPlayer(message) if (!mediaPlayer!!.isPlaying) { mediaPlayer!!.start() } mediaPlayerHandler = Handler() activity?.runOnUiThread(object : Runnable { override fun run() { if (mediaPlayer != null) { val currentPosition: Int = mediaPlayer!!.currentPosition / VOICE_MESSAGE_SEEKBAR_BASE message.voiceMessagePlayedSeconds = currentPosition adapter?.update(message) } mediaPlayerHandler.postDelayed(this, SECOND) } }) message.isDownloadingVoiceMessage = false message.isPlayingVoiceMessage = true adapter?.update(message) } private fun pausePlayback(message: ChatMessage) { if (mediaPlayer!!.isPlaying) { mediaPlayer!!.pause() } message.isPlayingVoiceMessage = false adapter?.update(message) } private fun initMediaPlayer(message: ChatMessage) { if (message != currentlyPlayedVoiceMessage) { currentlyPlayedVoiceMessage?.let { stopMediaPlayer(it) } } if (mediaPlayer == null) { val fileName = message.selectedIndividualHashMap!!["name"] val absolutePath = context!!.cacheDir.absolutePath + "/" + fileName mediaPlayer = MediaPlayer().apply { setDataSource(absolutePath) prepare() } currentlyPlayedVoiceMessage = message message.voiceMessageDuration = mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE mediaPlayer!!.setOnCompletionListener { stopMediaPlayer(message) } } else { Log.e(TAG, "mediaPlayer was not null. This should not happen!") } } private fun stopMediaPlayer(message: ChatMessage) { message.isPlayingVoiceMessage = false message.resetVoiceMessage = true adapter?.update(message) currentlyPlayedVoiceMessage = null mediaPlayerHandler.removeCallbacksAndMessages(null) mediaPlayer?.stop() mediaPlayer?.release() mediaPlayer = null } override fun updateMediaPlayerProgressBySlider(messageWithSlidedProgress: ChatMessage, progress: Int) { if (mediaPlayer != null) { if (messageWithSlidedProgress == currentlyPlayedVoiceMessage) { mediaPlayer!!.seekTo(progress * VOICE_MESSAGE_SEEKBAR_BASE) } } } @SuppressLint("LongLogTag") private fun downloadFileToCache(message: ChatMessage) { message.isDownloadingVoiceMessage = true adapter?.update(message) val baseUrl = message.activeUser!!.baseUrl val userId = message.activeUser!!.userId val attachmentFolder = CapabilitiesUtilNew.getAttachmentFolder(message.activeUser!!) val fileName = message.selectedIndividualHashMap!!["name"] var size = message.selectedIndividualHashMap!!["size"] if (size == null) { size = "-1" } val fileSize = size.toLong() val fileId = message.selectedIndividualHashMap!!["id"] val path = message.selectedIndividualHashMap!!["path"] // check if download worker is already running val workers = WorkManager.getInstance( context!! ).getWorkInfosByTag(fileId!!) try { for (workInfo in workers.get()) { if (workInfo.state == WorkInfo.State.RUNNING || workInfo.state == WorkInfo.State.ENQUEUED) { Log.d(TAG, "Download worker for " + fileId + " is already running or scheduled") return } } } catch (e: ExecutionException) { Log.e(TAG, "Error when checking if worker already exists", e) } catch (e: InterruptedException) { Log.e(TAG, "Error when checking if worker already exists", e) } val data: Data = Data.Builder() .putString(DownloadFileToCacheWorker.KEY_BASE_URL, baseUrl) .putString(DownloadFileToCacheWorker.KEY_USER_ID, userId) .putString(DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, attachmentFolder) .putString(DownloadFileToCacheWorker.KEY_FILE_NAME, fileName) .putString(DownloadFileToCacheWorker.KEY_FILE_PATH, path) .putLong(DownloadFileToCacheWorker.KEY_FILE_SIZE, fileSize) .build() val downloadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(DownloadFileToCacheWorker::class.java) .setInputData(data) .addTag(fileId) .build() WorkManager.getInstance().enqueue(downloadWorker) WorkManager.getInstance(context!!).getWorkInfoByIdLiveData(downloadWorker.id) .observeForever { workInfo: WorkInfo -> if (workInfo.state == WorkInfo.State.SUCCEEDED) { startPlayback(message) } } } @SuppressLint("SimpleDateFormat") private fun setVoiceRecordFileName() { val pattern = "yyyy-MM-dd HH-mm-ss" val simpleDateFormat = SimpleDateFormat(pattern) val date: String = simpleDateFormat.format(Date()) val fileNameWithoutSuffix = String.format( context!!.resources.getString(R.string.nc_voice_message_filename), date, currentConversation!!.displayName ) val fileName = fileNameWithoutSuffix + VOICE_MESSAGE_FILE_SUFFIX currentVoiceRecordFile = "${context!!.cacheDir.absolutePath}/$fileName" } private fun showRecordAudioUi(show: Boolean) { if (show) { binding.messageInputView.microphoneEnabledInfo.visibility = View.VISIBLE binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.VISIBLE binding.messageInputView.audioRecordDuration.visibility = View.VISIBLE binding.messageInputView.slideToCancelDescription.visibility = View.VISIBLE binding.messageInputView.attachmentButton.visibility = View.GONE binding.messageInputView.smileyButton.visibility = View.GONE binding.messageInputView.messageInput.visibility = View.GONE binding.messageInputView.messageInput.hint = "" } else { binding.messageInputView.microphoneEnabledInfo.visibility = View.GONE binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.GONE binding.messageInputView.audioRecordDuration.visibility = View.GONE binding.messageInputView.slideToCancelDescription.visibility = View.GONE binding.messageInputView.attachmentButton.visibility = View.VISIBLE binding.messageInputView.smileyButton.visibility = View.VISIBLE binding.messageInputView.messageInput.visibility = View.VISIBLE binding.messageInputView.messageInput.hint = context?.resources?.getString(R.string.nc_hint_enter_a_message) } } private fun isRecordAudioPermissionGranted(): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { return PermissionChecker.checkSelfPermission( context!!, Manifest.permission.RECORD_AUDIO ) == PermissionChecker.PERMISSION_GRANTED } else { true } } private fun startAudioRecording(file: String) { binding.messageInputView.audioRecordDuration.base = SystemClock.elapsedRealtime() binding.messageInputView.audioRecordDuration.start() val animation: Animation = AlphaAnimation(1.0f, 0.0f) animation.duration = ANIMATION_DURATION animation.interpolator = LinearInterpolator() animation.repeatCount = Animation.INFINITE animation.repeatMode = Animation.REVERSE binding.messageInputView.microphoneEnabledInfo.startAnimation(animation) recorder = MediaRecorder().apply { setAudioSource(MediaRecorder.AudioSource.MIC) setOutputFile(file) setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) setAudioEncoder(MediaRecorder.AudioEncoder.AAC) try { prepare() } catch (e: IOException) { Log.e(TAG, "prepare for audio recording failed") } try { start() isVoiceRecordingInProgress = true } catch (e: IllegalStateException) { Log.e(TAG, "start for audio recording failed") } vibrate() } } private fun stopAndSendAudioRecording() { stopAudioRecording() val uri = Uri.fromFile(File(currentVoiceRecordFile)) uploadFiles(mutableListOf(uri.toString()), true) } private fun stopAndDiscardAudioRecording() { stopAudioRecording() val cachedFile = File(currentVoiceRecordFile) cachedFile.delete() } @Suppress("Detekt.TooGenericExceptionCaught") private fun stopAudioRecording() { binding.messageInputView.audioRecordDuration.stop() binding.messageInputView.microphoneEnabledInfo.clearAnimation() if (isVoiceRecordingInProgress) { recorder?.apply { try { stop() release() isVoiceRecordingInProgress = false Log.d(TAG, "stopped recorder. isVoiceRecordingInProgress = false") } catch (e: RuntimeException) { Log.w(TAG, "error while stopping recorder!") } vibrate() } recorder = null } else { Log.e(TAG, "tried to stop audio recorder but it was not recording") } } fun vibrate() { val vibrator = context?.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator if (Build.VERSION.SDK_INT >= O) { vibrator.vibrate(VibrationEffect.createOneShot(SHORT_VIBRATE, VibrationEffect.DEFAULT_AMPLITUDE)) } else { vibrator.vibrate(SHORT_VIBRATE) } } private fun requestRecordAudioPermissions() { requestPermissions( arrayOf( Manifest.permission.RECORD_AUDIO ), REQUEST_RECORD_AUDIO_PERMISSION ) } private fun requestCameraPermissions() { requestPermissions( arrayOf( Manifest.permission.CAMERA ), REQUEST_CAMERA_PERMISSION ) } private fun requestReadContacts() { requestPermissions( arrayOf( Manifest.permission.READ_CONTACTS ), REQUEST_READ_CONTACT_PERMISSION ) } private fun checkShowCallButtons() { if (isAlive()) { if (isReadOnlyConversation() || shouldShowLobby()) { disableCallButtons() } else { enableCallButtons() } } } private fun checkShowMessageInputView() { if (isAlive()) { if (isReadOnlyConversation() || shouldShowLobby() || !hasChatPermission ) { binding.messageInputView.visibility = View.GONE } else { binding.messageInputView.visibility = View.VISIBLE } } } private fun shouldShowLobby(): Boolean { if (currentConversation != null) { return currentConversation?.shouldShowLobby(conversationUser!!) == true } return false } private fun disableCallButtons() { if (CapabilitiesUtilNew.isAbleToCall(conversationUser)) { if (conversationVoiceCallMenuItem != null && conversationVideoMenuItem != null) { conversationVoiceCallMenuItem?.icon?.alpha = SEMI_TRANSPARENT_INT conversationVideoMenuItem?.icon?.alpha = SEMI_TRANSPARENT_INT conversationVoiceCallMenuItem?.isEnabled = false conversationVideoMenuItem?.isEnabled = false } else { Log.e(TAG, "call buttons were null when trying to disable them") } } } private fun enableCallButtons() { if (CapabilitiesUtilNew.isAbleToCall(conversationUser)) { if (conversationVoiceCallMenuItem != null && conversationVideoMenuItem != null) { conversationVoiceCallMenuItem?.icon?.alpha = FULLY_OPAQUE_INT conversationVideoMenuItem?.icon?.alpha = FULLY_OPAQUE_INT conversationVoiceCallMenuItem?.isEnabled = true conversationVideoMenuItem?.isEnabled = true } else { Log.e(TAG, "call buttons were null when trying to enable them") } } } private fun isReadOnlyConversation(): Boolean { return currentConversation?.conversationReadOnlyState != null && currentConversation?.conversationReadOnlyState == Conversation.ConversationReadOnlyState.CONVERSATION_READ_ONLY } private fun checkLobbyState() { if (currentConversation != null && currentConversation?.isLobbyViewApplicable(conversationUser!!) ?: false && isAlive() ) { if (!checkingLobbyStatus) { getRoomInfo() } if (currentConversation?.shouldShowLobby(conversationUser!!) ?: false) { binding.lobby.lobbyView.visibility = View.VISIBLE binding.messagesListView.visibility = View.GONE binding.messageInputView.visibility = View.GONE binding.progressBar.visibility = View.GONE val sb = StringBuilder() sb.append(resources!!.getText(R.string.nc_lobby_waiting)) .append("\n\n") if (currentConversation?.lobbyTimer != null && currentConversation?.lobbyTimer != 0L ) { val timestamp = currentConversation?.lobbyTimer ?: 0 val stringWithStartDate = String.format( resources!!.getString(R.string.nc_lobby_start_date), DateUtils.getLocalDateStringFromTimestampForLobby(timestamp) ) val relativeTime = DateUtils.relativeStartTimeForLobby(timestamp, resources!!) sb.append("$stringWithStartDate - $relativeTime") .append("\n\n") } sb.append(currentConversation!!.description) binding.lobby.lobbyTextView.text = sb.toString() } else { binding.lobby.lobbyView.visibility = View.GONE binding.messagesListView.visibility = View.VISIBLE binding.messageInputView.inputEditText?.visibility = View.VISIBLE if (isFirstMessagesProcessing && pastPreconditionFailed) { pastPreconditionFailed = false pullChatMessages(0) } else if (futurePreconditionFailed) { futurePreconditionFailed = false pullChatMessages(1) } } } else { binding.lobby.lobbyView.visibility = View.GONE binding.messagesListView.visibility = View.VISIBLE binding.messageInputView.inputEditText?.visibility = View.VISIBLE } } override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) { if (resultCode != RESULT_OK && (requestCode != REQUEST_CODE_MESSAGE_SEARCH)) { Log.e(TAG, "resultCode for received intent was != ok") return } when (requestCode) { REQUEST_CODE_SELECT_REMOTE_FILES -> { val pathList = intent?.getStringArrayListExtra(RemoteFileBrowserActivity.EXTRA_SELECTED_PATHS) if (pathList?.size!! >= 1) { pathList .chunked(CHUNK_SIZE) .forEach { paths -> val data = Data.Builder() .putLong(KEY_INTERNAL_USER_ID, conversationUser!!.id!!) .putString(KEY_ROOM_TOKEN, roomToken) .putStringArray(KEY_FILE_PATHS, paths.toTypedArray()) .build() val worker = OneTimeWorkRequest.Builder(ShareOperationWorker::class.java) .setInputData(data) .build() WorkManager.getInstance().enqueue(worker) } } } REQUEST_CODE_CHOOSE_FILE -> { try { checkNotNull(intent) filesToUpload.clear() intent.clipData?.let { for (index in 0 until it.itemCount) { filesToUpload.add(it.getItemAt(index).uri.toString()) } } ?: run { checkNotNull(intent.data) intent.data.let { filesToUpload.add(intent.data.toString()) } } require(filesToUpload.isNotEmpty()) val filenamesWithLinebreaks = StringBuilder("\n") for (file in filesToUpload) { val filename = UriUtils.getFileName(Uri.parse(file), context) filenamesWithLinebreaks.append(filename).append("\n") } val confirmationQuestion = when (filesToUpload.size) { 1 -> context?.resources?.getString(R.string.nc_upload_confirm_send_single)?.let { String.format(it, title) } else -> context?.resources?.getString(R.string.nc_upload_confirm_send_multiple)?.let { String.format(it, title) } } LovelyStandardDialog(activity) .setPositiveButtonColorRes(R.color.nc_darkGreen) .setTitle(confirmationQuestion) .setMessage(filenamesWithLinebreaks.toString()) .setPositiveButton(R.string.nc_yes) { v -> if (UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) { uploadFiles(filesToUpload, false) } else { UploadAndShareFilesWorker.requestStoragePermission(this) } } .setNegativeButton(R.string.nc_no) { // unused atm } .show() } catch (e: IllegalStateException) { Toast.makeText(context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG) .show() Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) } catch (e: IllegalArgumentException) { Toast.makeText(context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG) .show() Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) } } REQUEST_CODE_SELECT_CONTACT -> { val contactUri = intent?.data ?: return val cursor: Cursor? = activity?.contentResolver!!.query(contactUri, null, null, null, null) if (cursor != null && cursor.moveToFirst()) { val id = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)) val fileName = ContactUtils.getDisplayNameFromDeviceContact(context!!, id) + ".vcf" val file = File(context?.cacheDir, fileName) writeContactToVcfFile(cursor, file) val shareUri = FileProvider.getUriForFile( activity!!, BuildConfig.APPLICATION_ID, File(file.absolutePath) ) uploadFiles(mutableListOf(shareUri.toString()), false) } cursor?.close() } REQUEST_CODE_PICK_CAMERA -> { if (resultCode == RESULT_OK) { try { checkNotNull(intent) filesToUpload.clear() run { checkNotNull(intent.data) intent.data.let { filesToUpload.add(intent.data.toString()) } } require(filesToUpload.isNotEmpty()) if (UploadAndShareFilesWorker.isStoragePermissionGranted(context!!)) { uploadFiles(filesToUpload, false) } else { UploadAndShareFilesWorker.requestStoragePermission(this) } } catch (e: IllegalStateException) { Toast.makeText( context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG ) .show() Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) } catch (e: IllegalArgumentException) { Toast.makeText( context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG ) .show() Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) } } } REQUEST_CODE_MESSAGE_SEARCH -> { val messageId = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_MESSAGE_ID) messageId?.let { id -> scrollToMessageWithId(id) } } } } private fun scrollToMessageWithId(messageId: String) { val position = adapter?.items?.indexOfFirst { it.item is ChatMessage && (it.item as ChatMessage).id == messageId } if (position != null && position >= 0) { binding.messagesListView.smoothScrollToPosition(position) } else { // TODO show error that we don't have that message? } } private fun writeContactToVcfFile(cursor: Cursor, file: File) { val lookupKey = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.LOOKUP_KEY)) val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey) val fd: AssetFileDescriptor = activity?.contentResolver!!.openAssetFileDescriptor(uri, "r")!! val fis = fd.createInputStream() file.createNewFile() fis.use { input -> file.outputStream().use { output -> input.copyTo(output) } } } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { if (requestCode == UploadAndShareFilesWorker.REQUEST_PERMISSION) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Log.d(ConversationsListController.TAG, "upload starting after permissions were granted") if (filesToUpload.isNotEmpty()) { uploadFiles(filesToUpload, false) } } else { Toast .makeText(context, context?.getString(R.string.read_storage_no_permission), Toast.LENGTH_LONG) .show() } } else if (requestCode == REQUEST_RECORD_AUDIO_PERMISSION) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { // do nothing. user will tap on the microphone again if he wants to record audio.. } else { Toast.makeText( context, context!!.getString(R.string.nc_voice_message_missing_audio_permission), Toast.LENGTH_LONG ).show() } } else if (requestCode == REQUEST_READ_CONTACT_PERMISSION) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { val intent = Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI) startActivityForResult(intent, REQUEST_CODE_SELECT_CONTACT) } else { Toast.makeText( context, context!!.getString(R.string.nc_share_contact_permission), Toast.LENGTH_LONG ).show() } } else if (requestCode == REQUEST_CAMERA_PERMISSION) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Log.d(TAG, "launch cam activity since permission for cam has been granted") startActivityForResult(TakePhotoActivity.createIntent(context!!), REQUEST_CODE_PICK_CAMERA) } else { Toast .makeText(context, context?.getString(R.string.take_photo_permission), Toast.LENGTH_LONG) .show() } } } private fun uploadFiles(files: MutableList, isVoiceMessage: Boolean) { var metaData = "" if (!hasChatPermission) { Log.w(TAG, "uploading file(s) is forbidden because of missing attendee permissions") return } if (isVoiceMessage) { metaData = VOICE_MESSAGE_META_DATA } try { require(files.isNotEmpty()) val data: Data = Data.Builder() .putStringArray(UploadAndShareFilesWorker.DEVICE_SOURCEFILES, files.toTypedArray()) .putString( UploadAndShareFilesWorker.NC_TARGETPATH, CapabilitiesUtilNew.getAttachmentFolder(conversationUser!!) ) .putString(UploadAndShareFilesWorker.ROOM_TOKEN, roomToken) .putString(UploadAndShareFilesWorker.META_DATA, metaData) .build() val uploadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(UploadAndShareFilesWorker::class.java) .setInputData(data) .build() WorkManager.getInstance().enqueue(uploadWorker) if (!isVoiceMessage) { Toast.makeText( context, context?.getString(R.string.nc_upload_in_progess), Toast.LENGTH_LONG ).show() } } catch (e: IllegalArgumentException) { Toast.makeText(context, context?.resources?.getString(R.string.nc_upload_failed), Toast.LENGTH_LONG).show() Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) } } fun sendSelectLocalFileIntent() { val action = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { type = "*/*" addCategory(Intent.CATEGORY_OPENABLE) putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) } startActivityForResult( Intent.createChooser( action, context?.resources?.getString( R.string.nc_upload_choose_local_files ) ), REQUEST_CODE_CHOOSE_FILE ) } fun sendChooseContactIntent() { requestReadContacts() } fun showBrowserScreen() { val sharingFileBrowserIntent = Intent(activity, RemoteFileBrowserActivity::class.java) startActivityForResult(sharingFileBrowserIntent, REQUEST_CODE_SELECT_REMOTE_FILES) } fun showShareLocationScreen() { Log.d(TAG, "showShareLocationScreen") val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, roomToken) router.pushController( RouterTransaction.with(LocationPickerController(bundle)) .pushChangeHandler(HorizontalChangeHandler()) .popChangeHandler(HorizontalChangeHandler()) ) } private fun showConversationInfoScreen() { val bundle = Bundle() bundle.putParcelable(BundleKeys.KEY_USER_ENTITY, conversationUser) bundle.putString(KEY_ROOM_TOKEN, roomToken) bundle.putBoolean(BundleKeys.KEY_ROOM_ONE_TO_ONE, inOneToOneCall()) router.pushController( RouterTransaction.with(ConversationInfoController(bundle)) .pushChangeHandler(HorizontalChangeHandler()) .popChangeHandler(HorizontalChangeHandler()) ) } private fun setupMentionAutocomplete() { if (isAlive()) { val elevation = MENTION_AUTO_COMPLETE_ELEVATION resources?.let { val backgroundDrawable = ColorDrawable(it.getColor(R.color.bg_default)) val presenter = MentionAutocompletePresenter(activity, roomToken) val callback = MentionAutocompleteCallback( activity, conversationUser!!, binding.messageInputView.inputEditText, viewThemeUtils ) if (mentionAutocomplete == null && binding.messageInputView.inputEditText != null) { mentionAutocomplete = Autocomplete.on(binding.messageInputView.inputEditText) .with(elevation) .with(backgroundDrawable) .with(MagicCharPolicy('@')) .with(presenter) .with(callback) .build() } } } } private fun validSessionId(): Boolean { return currentConversation != null && !TextUtils.isEmpty(currentConversation?.sessionId) && currentConversation?.sessionId != "0" } @Suppress("Detekt.TooGenericExceptionCaught") override fun onAttach(view: View) { super.onAttach(view) Log.d( TAG, "onAttach: Controller: " + System.identityHashCode(this).toString() + " Activity: " + System.identityHashCode(activity).toString() ) eventBus.register(this) if (conversationUser?.userId != "?" && CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "mention-flag") ?: false && activity != null ) { activity?.findViewById(R.id.toolbar)?.setOnClickListener { v -> showConversationInfoScreen() } } ApplicationWideCurrentRoomHolder.getInstance().currentRoomId = roomId ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = roomToken ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser val smileyButton = binding.messageInputView.findViewById(R.id.smileyButton) emojiPopup = binding.messageInputView.inputEditText?.let { EmojiPopup( rootView = view, editText = it, onEmojiPopupShownListener = { if (resources != null) { smileyButton?.setImageDrawable( ContextCompat.getDrawable(context!!, R.drawable.ic_baseline_keyboard_24) ) } }, onEmojiPopupDismissListener = { smileyButton?.setImageDrawable( ContextCompat.getDrawable(context!!, R.drawable.ic_insert_emoticon_black_24dp) ) }, onEmojiClickListener = { try { binding.messageInputView.inputEditText?.editableText?.append(" ") } catch (npe: NullPointerException) { // view binding can be null // since this is called asynchronously and UI might have been destroyed in the meantime Log.i(WebViewLoginController.TAG, "UI destroyed - view binding already gone") } } ) } smileyButton?.setOnClickListener { emojiPopup?.toggle() } binding.messageInputView.findViewById(R.id.cancelReplyButton)?.setOnClickListener { cancelReply() } viewThemeUtils.themeImageButton(binding.messageInputView.findViewById(R.id.cancelReplyButton)) cancelNotificationsForCurrentConversation() Log.d(TAG, "onAttach inConversation: " + inConversation.toString()) if (inConversation) { Log.d(TAG, "execute joinRoomWithPassword in onAttach") joinRoomWithPassword() } } private fun cancelReply() { binding.messageInputView.findViewById(R.id.quotedChatMessageView)?.visibility = View.GONE binding.messageInputView.findViewById(R.id.attachmentButton)?.visibility = View.VISIBLE } @Suppress("Detekt.TooGenericExceptionCaught") private fun cancelNotificationsForCurrentConversation() { if (conversationUser != null) { if (!TextUtils.isEmpty(roomToken)) { try { NotificationUtils.cancelExistingNotificationsForRoom( applicationContext, conversationUser, roomToken!! ) } catch (e: RuntimeException) { Log.w(TAG, "Cancel notifications for current conversation results with an error.", e) } } } } override fun onDetach(view: View) { super.onDetach(view) Log.d( TAG, "onDetach: Controller: " + System.identityHashCode(this).toString() + " Activity: " + System.identityHashCode(activity).toString() ) eventBus.unregister(this) if (activity != null) { activity?.findViewById(R.id.toolbar)?.setOnClickListener(null) } if (conversationUser != null && isActivityNotChangingConfigurations() && isNotInCall()) { ApplicationWideCurrentRoomHolder.getInstance().clear() if (inConversation && validSessionId()) { leaveRoom() } } if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { mentionAutocomplete?.dismissPopup() } } private fun isActivityNotChangingConfigurations(): Boolean { return activity != null && !activity?.isChangingConfigurations!! } private fun isNotInCall(): Boolean { return !ApplicationWideCurrentRoomHolder.getInstance().isInCall && !ApplicationWideCurrentRoomHolder.getInstance().isDialing } override val title: String get() = if (currentConversation?.displayName != null) { try { " " + EmojiCompat.get().process(currentConversation?.displayName as CharSequence).toString() } catch (e: IllegalStateException) { " " + currentConversation?.displayName } } else { "" } public override fun onDestroy() { super.onDestroy() if (activity != null) { activity?.findViewById(R.id.toolbar)?.setOnClickListener(null) } if (actionBar != null) { actionBar?.setIcon(null) } currentlyPlayedVoiceMessage?.let { stopMediaPlayer(it) } adapter = null inConversation = false } private fun joinRoomWithPassword() { Log.d( TAG, "joinRoomWithPassword start: " + (currentConversation == null).toString() + " sessionId: " + currentConversation?.sessionId ) if (!validSessionId()) { var apiVersion = 1 // FIXME Fix API checking with guests? if (conversationUser != null) { apiVersion = ApiUtils.getConversationApiVersion(conversationUser!!, intArrayOf(ApiUtils.APIv4, 1)) } val startNanoTime = System.nanoTime() Log.d(TAG, "joinRoomWithPassword - joinRoom - calling: " + startNanoTime) ncApi.joinRoom( credentials, ApiUtils.getUrlForParticipantsActive(apiVersion, conversationUser?.baseUrl, roomToken), roomPassword ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.retry(RETRIES) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { disposables.add(d) } @Suppress("Detekt.TooGenericExceptionCaught") override fun onNext(roomOverall: RoomOverall) { Log.d(TAG, "joinRoomWithPassword - joinRoom - got response: " + startNanoTime) inConversation = true currentConversation?.sessionId = roomOverall.ocs!!.data!!.sessionId Log.d(TAG, "joinRoomWithPassword - sessionId: " + currentConversation?.sessionId) ApplicationWideCurrentRoomHolder.getInstance().session = currentConversation?.sessionId setupWebsocket() try { checkLobbyState() } catch (npe: NullPointerException) { // view binding can be null // since this is called asynchrously and UI might have been destroyed in the meantime Log.i(TAG, "UI destroyed - view binding already gone") } if (isFirstMessagesProcessing) { pullChatMessages(0) } else { pullChatMessages(1, 0) } if (magicWebSocketInstance != null) { magicWebSocketInstance?.joinRoomWithRoomTokenAndSession( roomToken, currentConversation?.sessionId ) } if (startCallFromNotification != null && startCallFromNotification ?: false) { startCallFromNotification = false startACall(voiceOnly, false) } } override fun onError(e: Throwable) { Log.e(TAG, "joinRoomWithPassword - joinRoom - ERROR", e) } override fun onComplete() { // unused atm } }) } else { inConversation = true ApplicationWideCurrentRoomHolder.getInstance().session = currentConversation?.sessionId if (magicWebSocketInstance != null) { magicWebSocketInstance?.joinRoomWithRoomTokenAndSession( roomToken, currentConversation?.sessionId ) } if (isFirstMessagesProcessing) { pullChatMessages(0) } else { pullChatMessages(1) } } } private fun leaveRoom() { Log.d(TAG, "leaveRoom") var apiVersion = 1 // FIXME Fix API checking with guests? if (conversationUser != null) { apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.APIv4, 1)) } val startNanoTime = System.nanoTime() Log.d(TAG, "leaveRoom - leaveRoom - calling: " + startNanoTime) ncApi.leaveRoom( credentials, ApiUtils.getUrlForParticipantsActive( apiVersion, conversationUser?.baseUrl, roomToken ) ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { disposables.add(d) } override fun onNext(genericOverall: GenericOverall) { Log.d(TAG, "leaveRoom - leaveRoom - got response: " + startNanoTime) checkingLobbyStatus = false if (lobbyTimerHandler != null) { lobbyTimerHandler?.removeCallbacksAndMessages(null) } if (magicWebSocketInstance != null && currentConversation != null) { magicWebSocketInstance?.joinRoomWithRoomTokenAndSession( "", currentConversation?.sessionId ) } else { Log.e(TAG, "magicWebSocketInstance or currentConversation were null! Failed to leave the room!") } currentConversation?.sessionId = "0" } override fun onError(e: Throwable) { Log.e(TAG, "leaveRoom - leaveRoom - ERROR", e) } override fun onComplete() { Log.d(TAG, "leaveRoom - leaveRoom - completed: " + startNanoTime) disposables.dispose() } }) } private fun submitMessage(sendWithoutNotification: Boolean) { if (binding.messageInputView.inputEditText != null) { val editable = binding.messageInputView.inputEditText!!.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") } binding.messageInputView.inputEditText?.setText("") val replyMessageId: Int? = view?.findViewById(R.id.quotedChatMessageView)?.tag as Int? sendMessage( editable, if ( view ?.findViewById(R.id.quotedChatMessageView) ?.visibility == View.VISIBLE ) replyMessageId else null, sendWithoutNotification ) cancelReply() } } private fun sendMessage(message: CharSequence, replyTo: Int?, sendWithoutNotification: Boolean) { if (conversationUser != null) { val apiVersion = ApiUtils.getChatApiVersion(conversationUser, intArrayOf(1)) ncApi!!.sendChatMessage( credentials, ApiUtils.getUrlForChat(apiVersion, conversationUser.baseUrl, roomToken), message, conversationUser.displayName, replyTo, sendWithoutNotification ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { // unused atm } @Suppress("Detekt.TooGenericExceptionCaught") override fun onNext(genericOverall: GenericOverall) { myFirstMessage = message try { if (binding.popupBubbleView.isShown == true) { binding.popupBubbleView.hide() } binding.messagesListView.smoothScrollToPosition(0) } catch (npe: NullPointerException) { // view binding can be null // since this is called asynchrously and UI might have been destroyed in the meantime Log.i(TAG, "UI destroyed - view binding already gone") } } override fun onError(e: Throwable) { if (e is HttpException) { val code = e.code() if (Integer.toString(code).startsWith("2")) { myFirstMessage = message if (binding.popupBubbleView.isShown == true) { binding.popupBubbleView.hide() } binding.messagesListView.smoothScrollToPosition(0) } } } override fun onComplete() { // unused atm } }) } showMicrophoneButton(true) } private fun setupWebsocket() { if (conversationUser != null) { if (WebSocketConnectionHelper.getMagicWebSocketInstanceForUserId(conversationUser.id!!) != null) { magicWebSocketInstance = WebSocketConnectionHelper.getMagicWebSocketInstanceForUserId(conversationUser.id!!) } else { Log.d(TAG, "magicWebSocketInstance became null") magicWebSocketInstance = null } } } fun pullChatMessages(lookIntoFuture: Int, setReadMarker: Int = 1, xChatLastCommonRead: Int? = null) { if (!inConversation) { return } if (pullChatMessagesPending) { // Sometimes pullChatMessages may be called before response to a previous call is received. // In such cases just ignore the second call. Message processing will continue when response to the // earlier call is received. // More details: https://github.com/nextcloud/talk-android/pull/1766 Log.d(TAG, "pullChatMessages - pullChatMessagesPending is true, exiting") return } pullChatMessagesPending = true if (currentConversation != null && currentConversation!!.shouldShowLobby(conversationUser!!)) { // return } val fieldMap = HashMap() fieldMap["includeLastKnown"] = 0 if (lookIntoFuture > 0) { lookingIntoFuture = true } else if (isFirstMessagesProcessing) { if (currentConversation != null) { globalLastKnownFutureMessageId = currentConversation!!.lastReadMessage globalLastKnownPastMessageId = currentConversation!!.lastReadMessage fieldMap["includeLastKnown"] = 1 } } val timeout = if (lookingIntoFuture) { LOOKING_INTO_FUTURE_TIMEOUT } else { 0 } fieldMap["timeout"] = timeout fieldMap["lookIntoFuture"] = lookIntoFuture fieldMap["limit"] = MESSAGE_PULL_LIMIT fieldMap["setReadMarker"] = setReadMarker val lastKnown: Int if (lookIntoFuture > 0) { lastKnown = globalLastKnownFutureMessageId } else { lastKnown = globalLastKnownPastMessageId } fieldMap["lastKnownMessageId"] = lastKnown xChatLastCommonRead?.let { fieldMap["lastCommonReadId"] = it } var apiVersion = 1 // FIXME this is a best guess, guests would need to get the capabilities themselves if (conversationUser != null) { apiVersion = ApiUtils.getChatApiVersion(conversationUser, intArrayOf(1)) } if (lookIntoFuture > 0) { val finalTimeout = timeout Log.d(TAG, "pullChatMessages - pullChatMessages[lookIntoFuture > 0] - calling") ncApi.pullChatMessages( credentials, ApiUtils.getUrlForChat(apiVersion, conversationUser?.baseUrl, roomToken), fieldMap ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer> { override fun onSubscribe(d: Disposable) { disposables.add(d) } @Suppress("Detekt.TooGenericExceptionCaught") override fun onNext(response: Response<*>) { Log.d(TAG, "pullChatMessages - pullChatMessages[lookIntoFuture > 0] - got response") pullChatMessagesPending = false try { if (response.code() == HTTP_CODE_NOT_MODIFIED) { Log.d(TAG, "pullChatMessages - quasi recursive call to pullChatMessages") pullChatMessages(1, setReadMarker, xChatLastCommonRead) } else if (response.code() == HTTP_CODE_PRECONDITION_FAILED) { futurePreconditionFailed = true } else { processMessages(response, true, finalTimeout) } } catch (npe: NullPointerException) { // view binding can be null // since this is called asynchrously and UI might have been destroyed in the meantime Log.i(TAG, "UI destroyed - view binding already gone") } } override fun onError(e: Throwable) { Log.e(TAG, "pullChatMessages - pullChatMessages[lookIntoFuture > 0] - ERROR", e) pullChatMessagesPending = false } override fun onComplete() { pullChatMessagesPending = false } }) } else { Log.d(TAG, "pullChatMessages - pullChatMessages[lookIntoFuture <= 0] - calling") ncApi.pullChatMessages( credentials, ApiUtils.getUrlForChat(apiVersion, conversationUser?.baseUrl, roomToken), fieldMap ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer> { override fun onSubscribe(d: Disposable) { disposables.add(d) } @Suppress("Detekt.TooGenericExceptionCaught") override fun onNext(response: Response<*>) { Log.d(TAG, "pullChatMessages - pullChatMessages[lookIntoFuture <= 0] - got response") pullChatMessagesPending = false try { if (response.code() == HTTP_CODE_PRECONDITION_FAILED) { pastPreconditionFailed = true } else { processMessages(response, false, 0) } } catch (e: NullPointerException) { // view binding can be null // since this is called asynchrously and UI might have been destroyed in the meantime Log.i(TAG, "UI destroyed - view binding already gone", e) } } override fun onError(e: Throwable) { Log.e(TAG, "pullChatMessages - pullChatMessages[lookIntoFuture <= 0] - ERROR", e) pullChatMessagesPending = false } override fun onComplete() { pullChatMessagesPending = false } }) } } private fun processMessages(response: Response<*>, isFromTheFuture: Boolean, timeout: Int) { val xChatLastGivenHeader: String? = response.headers().get("X-Chat-Last-Given") val xChatLastCommonRead = response.headers().get("X-Chat-Last-Common-Read")?.let { Integer.parseInt(it) } if (response.headers().size > 0 && !TextUtils.isEmpty(xChatLastGivenHeader)) { val header = Integer.parseInt(xChatLastGivenHeader!!) if (header > 0) { if (isFromTheFuture) { globalLastKnownFutureMessageId = header } else { if (globalLastKnownFutureMessageId == -1) { globalLastKnownFutureMessageId = header } globalLastKnownPastMessageId = header } } } if (response.code() == HTTP_CODE_OK) { val chatOverall = response.body() as ChatOverall? val chatMessageList = handleSystemMessages(chatOverall?.ocs!!.data!!) if (chatMessageList.isNotEmpty() && ChatMessage.SystemMessageType.CLEARED_CHAT == chatMessageList[0].systemMessageType ) { adapter?.clear() adapter?.notifyDataSetChanged() } if (isFirstMessagesProcessing) { cancelNotificationsForCurrentConversation() isFirstMessagesProcessing = false binding.progressBar.visibility = View.GONE binding.messagesListView.visibility = View.VISIBLE } var countGroupedMessages = 0 if (!isFromTheFuture) { var previousMessageId = NO_PREVIOUS_MESSAGE_ID for (i in chatMessageList.indices.reversed()) { val chatMessage = chatMessageList[i] if (previousMessageId > NO_PREVIOUS_MESSAGE_ID) { chatMessage.previousMessageId = previousMessageId } else if (adapter?.isEmpty != true) { if (adapter!!.items[0].item is ChatMessage) { chatMessage.previousMessageId = (adapter!!.items[0].item as ChatMessage).jsonMessageId } else if (adapter!!.items.size > 1 && adapter!!.items[1].item is ChatMessage) { chatMessage.previousMessageId = (adapter!!.items[1].item as ChatMessage).jsonMessageId } } previousMessageId = chatMessage.jsonMessageId } for (i in chatMessageList.indices) { if (chatMessageList.size > i + 1) { if (isSameDayNonSystemMessages(chatMessageList[i], chatMessageList[i + 1]) && chatMessageList[i + 1].actorId == chatMessageList[i].actorId && countGroupedMessages < GROUPED_MESSAGES_THRESHOLD ) { 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.activeUser = conversationUser } if (adapter != null) { adapter?.addToEnd(chatMessageList, false) } scrollToRequestedMessageIfNeeded() } else { var chatMessage: ChatMessage val shouldAddNewMessagesNotice = (adapter?.itemCount ?: 0) > 0 && chatMessageList.isNotEmpty() 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 var previousMessageId = NO_PREVIOUS_MESSAGE_ID for (i in chatMessageList.indices.reversed()) { val chatMessageItem = chatMessageList[i] if (previousMessageId > NO_PREVIOUS_MESSAGE_ID) { chatMessageItem.previousMessageId = previousMessageId } else if (adapter?.isEmpty != true) { if (adapter!!.items[0].item is ChatMessage) { chatMessageItem.previousMessageId = (adapter!!.items[0].item as ChatMessage).jsonMessageId } else if (adapter!!.items.size > 1 && adapter!!.items[1].item is ChatMessage) { chatMessageItem.previousMessageId = (adapter!!.items[1].item as ChatMessage).jsonMessageId } } previousMessageId = chatMessageItem.jsonMessageId } for (i in chatMessageList.indices) { chatMessage = chatMessageList[i] chatMessage.activeUser = conversationUser val shouldScroll = !isThereANewNotice && !shouldAddNewMessagesNotice && layoutManager?.findFirstVisibleItemPosition() == 0 || adapter != null && adapter?.itemCount == 0 if (!shouldAddNewMessagesNotice && !shouldScroll) { if (!binding.popupBubbleView.isShown) { newMessagesCount = 1 binding.popupBubbleView.show() } else if (binding.popupBubbleView.isShown == true) { newMessagesCount++ } } else { newMessagesCount = 0 } if (adapter != null) { chatMessage.isGrouped = ( adapter!!.isPreviousSameAuthor( chatMessage.actorId, -1 ) && adapter!!.getSameAuthorLastMessagesCount(chatMessage.actorId) % GROUPED_MESSAGES_SAME_AUTHOR_THRESHOLD > 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"), binding.messagesListView.height / 2 ) } } // update read status of all messages for (message in adapter!!.items) { xChatLastCommonRead?.let { if (message.item is ChatMessage) { val chatMessage = message.item as ChatMessage if (chatMessage.jsonMessageId <= it) { chatMessage.readStatus = ReadStatus.READ } else { chatMessage.readStatus = ReadStatus.SENT } } } } adapter?.notifyDataSetChanged() if (inConversation) { pullChatMessages(1, 1, xChatLastCommonRead) } } else if (response.code() == HTTP_CODE_NOT_MODIFIED && !isFromTheFuture) { if (isFirstMessagesProcessing) { cancelNotificationsForCurrentConversation() isFirstMessagesProcessing = false binding.progressBar.visibility = View.GONE } historyRead = true if (!lookingIntoFuture && inConversation) { pullChatMessages(1) } } } private fun scrollToRequestedMessageIfNeeded() { args.getString(BundleKeys.KEY_MESSAGE_ID)?.let { scrollToMessageWithId(it) } } private fun isSameDayNonSystemMessages(messageLeft: ChatMessage, messageRight: ChatMessage): Boolean { return TextUtils.isEmpty(messageLeft.systemMessage) && TextUtils.isEmpty(messageRight.systemMessage) && DateFormatter.isSameDay(messageLeft.createdAt, messageRight.createdAt) } 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) viewThemeUtils.colorToolbarMenuIcon( binding.messageInputView.context, menu.findItem(R.id.conversation_voice_call) ) viewThemeUtils.colorToolbarMenuIcon( binding.messageInputView.context, menu.findItem(R.id.conversation_video_call) ) if (conversationUser?.userId == "?") { menu.removeItem(R.id.conversation_info) } else { conversationInfoMenuItem = menu.findItem(R.id.conversation_info) if (CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "rich-object-list-media")) { conversationSharedItemsItem = menu.findItem(R.id.shared_items) } else { menu.removeItem(R.id.shared_items) } loadAvatarForStatusBar() } if (CapabilitiesUtilNew.isAbleToCall(conversationUser)) { conversationVoiceCallMenuItem = menu.findItem(R.id.conversation_voice_call) conversationVideoMenuItem = menu.findItem(R.id.conversation_video_call) if (CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "silent-call")) { Handler().post { activity?.findViewById(R.id.conversation_voice_call)?.setOnLongClickListener { showCallButtonMenu(true) true } } Handler().post { activity?.findViewById(R.id.conversation_video_call)?.setOnLongClickListener { showCallButtonMenu(false) true } } } } else { menu.removeItem(R.id.conversation_video_call) menu.removeItem(R.id.conversation_voice_call) } } override fun onPrepareOptionsMenu(menu: Menu) { super.onPrepareOptionsMenu(menu) conversationUser?.let { if (CapabilitiesUtilNew.hasSpreedFeatureCapability(it, "read-only-rooms")) { checkShowCallButtons() } val searchItem = menu.findItem(R.id.conversation_search) searchItem.isVisible = CapabilitiesUtilNew.isUnifiedSearchAvailable(it) } } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { android.R.id.home -> { (activity as MainActivity).resetConversationsList() true } R.id.conversation_video_call -> { startACall(false, false) true } R.id.conversation_voice_call -> { startACall(true, false) true } R.id.conversation_info -> { showConversationInfoScreen() true } R.id.shared_items -> { showSharedItems() true } R.id.conversation_search -> { startMessageSearch() true } else -> super.onOptionsItemSelected(item) } } private fun showSharedItems() { val intent = Intent(activity, SharedItemsActivity::class.java) intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName) intent.putExtra(KEY_ROOM_TOKEN, roomToken) intent.putExtra(KEY_USER_ENTITY, conversationUser as Parcelable) intent.putExtra( SharedItemsActivity.KEY_USER_IS_OWNER_OR_MODERATOR, currentConversation?.isParticipantOwnerOrModerator ) activity!!.startActivity(intent) } private fun startMessageSearch() { val intent = Intent(activity, MessageSearchActivity::class.java) intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName) intent.putExtra(KEY_ROOM_TOKEN, roomToken) startActivityForResult(intent, REQUEST_CODE_MESSAGE_SEARCH) } private fun handleSystemMessages(chatMessageList: List): List { val chatMessageMap = chatMessageList.map { it.id to it }.toMap().toMutableMap() val chatMessageIterator = chatMessageMap.iterator() while (chatMessageIterator.hasNext()) { val currentMessage = chatMessageIterator.next() // setDeletionFlagsAndRemoveInfomessages if (isInfoMessageAboutDeletion(currentMessage)) { if (!chatMessageMap.containsKey(currentMessage.value.parentMessage!!.id)) { // if chatMessageMap doesn't contain message to delete (this happens when lookingIntoFuture), // the message to delete has to be modified directly inside the adapter setMessageAsDeleted(currentMessage.value.parentMessage) } else { chatMessageMap[currentMessage.value.parentMessage!!.id]!!.isDeleted = true } chatMessageIterator.remove() } // delete reactions system messages else if (isReactionsMessage(currentMessage)) { if (!chatMessageMap.containsKey(currentMessage.value.parentMessage!!.id)) { updateAdapterForReaction(currentMessage.value.parentMessage) } chatMessageIterator.remove() } // delete poll system messages else if (isPollVotedMessage(currentMessage)) { chatMessageIterator.remove() } } return chatMessageMap.values.toList() } private fun isInfoMessageAboutDeletion(currentMessage: MutableMap.MutableEntry): Boolean { return currentMessage.value.parentMessage != null && currentMessage.value.systemMessageType == ChatMessage .SystemMessageType.MESSAGE_DELETED } private fun isReactionsMessage(currentMessage: MutableMap.MutableEntry): Boolean { return currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION || currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION_DELETED || currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION_REVOKED } private fun isPollVotedMessage(currentMessage: MutableMap.MutableEntry): Boolean { return currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.POLL_VOTED } private fun startACall(isVoiceOnlyCall: Boolean, callWithoutNotification: Boolean) { if (currentConversation?.canStartCall == false && currentConversation?.hasCall == false) { Toast.makeText(context, R.string.startCallForbidden, Toast.LENGTH_LONG).show() } else { ApplicationWideCurrentRoomHolder.getInstance().isDialing = true val callIntent = getIntentForCall(isVoiceOnlyCall, callWithoutNotification) if (callIntent != null) { startActivity(callIntent) } } } private fun getIntentForCall(isVoiceOnlyCall: Boolean, callWithoutNotification: Boolean): Intent? { currentConversation?.let { val bundle = Bundle() bundle.putString(KEY_ROOM_TOKEN, roomToken) bundle.putString(KEY_ROOM_ID, roomId) bundle.putParcelable(KEY_USER_ENTITY, conversationUser) bundle.putString(BundleKeys.KEY_CONVERSATION_PASSWORD, roomPassword) bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, conversationUser?.baseUrl) bundle.putString(KEY_CONVERSATION_NAME, it.displayName) if (isVoiceOnlyCall) { bundle.putBoolean(BundleKeys.KEY_CALL_VOICE_ONLY, true) } if (callWithoutNotification) { bundle.putBoolean(BundleKeys.KEY_CALL_WITHOUT_NOTIFICATION, true) } return if (activity != null) { val callIntent = Intent(activity, CallActivity::class.java) callIntent.putExtras(bundle) callIntent } else { null } } ?: run { return null } } override fun onClickReactions(chatMessage: ChatMessage) { activity?.let { ShowReactionsDialog( activity!!, currentConversation, chatMessage, conversationUser, hasChatPermission, ncApi!!, serverTheme ).show() } } override fun onLongClickReactions(chatMessage: ChatMessage) { openMessageActionsDialog(chatMessage) } override fun onMessageViewLongClick(view: View?, message: IMessage?) { openMessageActionsDialog(message) } override fun onPreviewMessageLongClick(chatMessage: ChatMessage) { openMessageActionsDialog(chatMessage) } private fun openMessageActionsDialog(iMessage: IMessage?) { val message = iMessage as ChatMessage if (hasVisibleItems(message) && !isSystemMessage(message)) { activity?.let { MessageActionsDialog( this, message, conversationUser, currentConversation, isShowMessageDeletionButton(message), hasChatPermission, ncApi!! ).show() } } } private fun isSystemMessage(message: ChatMessage): Boolean { return ChatMessage.MessageType.SYSTEM_MESSAGE == message.getCalculateMessageType() } fun deleteMessage(message: IMessage?) { if (!hasChatPermission) { Log.w( TAG, "Deletion of message is skipped because of restrictions by permissions. " + "This method should not have been called!" ) Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show() } else { var apiVersion = 1 // FIXME Fix API checking with guests? if (conversationUser != null) { apiVersion = ApiUtils.getChatApiVersion(conversationUser, intArrayOf(1)) } ncApi.deleteChatMessage( credentials, ApiUtils.getUrlForChatMessage( apiVersion, conversationUser?.baseUrl, roomToken, message?.id ) )?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { // unused atm } override fun onNext(t: ChatOverallSingleMessage) { if (t.ocs!!.meta!!.statusCode == HttpURLConnection.HTTP_ACCEPTED) { Toast.makeText( context, R.string.nc_delete_message_leaked_to_matterbridge, Toast.LENGTH_LONG ).show() } } override fun onError(e: Throwable) { Log.e( TAG, "Something went wrong when trying to delete message with id " + message?.id, e ) Toast.makeText(context, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show() } override fun onComplete() { // unused atm } }) } } fun replyPrivately(message: IMessage?) { val apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.APIv4, 1)) val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom( apiVersion, conversationUser?.baseUrl, "1", null, message?.user?.id?.substring(INVITE_LENGTH), null ) ncApi!!.createRoom( credentials, retrofitBucket.url, retrofitBucket.queryMap ) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(object : Observer { override fun onSubscribe(d: Disposable) { // unused atm } override fun onNext(roomOverall: RoomOverall) { val bundle = Bundle() bundle.putParcelable(KEY_USER_ENTITY, conversationUser) bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token) bundle.putString(KEY_ROOM_ID, roomOverall.ocs!!.data!!.roomId) // FIXME once APIv2+ is used only, the createRoom already returns all the data ncApi!!.getRoom( credentials, ApiUtils.getUrlForRoom( apiVersion, conversationUser?.baseUrl, roomOverall.ocs!!.data!!.token ) ) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(object : Observer { override fun onSubscribe(d: Disposable) { // unused atm } override fun onNext(roomOverall: RoomOverall) { bundle.putParcelable( KEY_ACTIVE_CONVERSATION, Parcels.wrap(roomOverall.ocs!!.data!!) ) remapChatController( router, conversationUser!!.id!!, roomOverall.ocs!!.data!!.token!!, bundle, true ) } override fun onError(e: Throwable) { Log.e(TAG, e.message, e) } override fun onComplete() { // unused atm } }) } override fun onError(e: Throwable) { Log.e(TAG, e.message, e) } override fun onComplete() { // unused atm } }) } fun forwardMessage(message: IMessage?) { val bundle = Bundle() bundle.putBoolean(BundleKeys.KEY_FORWARD_MSG_FLAG, true) bundle.putString(BundleKeys.KEY_FORWARD_MSG_TEXT, message?.text) bundle.putString(BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM, roomId) router.pushController( RouterTransaction.with(ConversationsListController(bundle)) .pushChangeHandler(HorizontalChangeHandler()) .popChangeHandler(HorizontalChangeHandler()) ) } fun markAsUnread(message: IMessage?) { val chatMessage = message as ChatMessage? if (chatMessage!!.previousMessageId > NO_PREVIOUS_MESSAGE_ID) { ncApi!!.setChatReadMarker( credentials, ApiUtils.getUrlForSetChatReadMarker( ApiUtils.getChatApiVersion(conversationUser, intArrayOf(ApiUtils.APIv1)), conversationUser?.baseUrl, roomToken ), chatMessage.previousMessageId ) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(object : Observer { override fun onSubscribe(d: Disposable) { // unused atm } override fun onNext(t: GenericOverall) { // unused atm } override fun onError(e: Throwable) { Log.e(TAG, e.message, e) } override fun onComplete() { // unused atm } }) } } fun copyMessage(message: IMessage?) { val clipboardManager = activity?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val clipData = ClipData.newPlainText( resources?.getString(R.string.nc_app_product_name), message?.text ) clipboardManager.setPrimaryClip(clipData) } private fun hasVisibleItems(message: ChatMessage): Boolean { return !message.isDeleted || // copy message message.replyable || // reply to message.replyable && // reply privately conversationUser?.userId?.isNotEmpty() == true && conversationUser?.userId != "?" && message.user.id.startsWith("users/") && message.user.id.substring(ACTOR_LENGTH) != currentConversation?.actorId && currentConversation?.type != Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL || isShowMessageDeletionButton(message) || // delete ChatMessage.MessageType.REGULAR_TEXT_MESSAGE == message.getCalculateMessageType() || // forward message.previousMessageId > NO_PREVIOUS_MESSAGE_ID && // mark as unread ChatMessage.MessageType.SYSTEM_MESSAGE != message.getCalculateMessageType() && BuildConfig.DEBUG } fun replyToMessage(message: IMessage?) { val chatMessage = message as ChatMessage? chatMessage?.let { binding.messageInputView.findViewById(R.id.attachmentButton)?.visibility = View.GONE binding.messageInputView.findViewById(R.id.cancelReplyButton)?.visibility = View.VISIBLE val quotedMessage = binding .messageInputView .findViewById(R.id.quotedMessage) quotedMessage?.maxLines = 2 quotedMessage?.ellipsize = TextUtils.TruncateAt.END quotedMessage?.text = it.text binding.messageInputView.findViewById(R.id.quotedMessageAuthor)?.text = it.actorDisplayName ?: context!!.getText(R.string.nc_nick_guest) conversationUser?.let { currentUser -> val quotedMessageImage = binding .messageInputView .findViewById(R.id.quotedMessageImage) chatMessage.imageUrl?.let { previewImageUrl -> quotedMessageImage?.visibility = View.VISIBLE val px = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, QUOTED_MESSAGE_IMAGE_MAX_HEIGHT, resources?.displayMetrics ) quotedMessageImage?.maxHeight = px.toInt() val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams layoutParams.flexGrow = 0f quotedMessageImage.layoutParams = layoutParams quotedMessageImage.load(previewImageUrl) { addHeader("Authorization", credentials!!) } } ?: run { binding .messageInputView .findViewById(R.id.quotedMessageImage) ?.visibility = View.GONE } } val quotedChatMessageView = binding .messageInputView .findViewById(R.id.quotedChatMessageView) quotedChatMessageView?.tag = message?.jsonMessageId quotedChatMessageView?.visibility = View.VISIBLE } } private fun showMicrophoneButton(show: Boolean) { if (show && CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "voice-message-sharing")) { binding.messageInputView.messageSendButton.visibility = View.GONE binding.messageInputView.recordAudioButton.visibility = View.VISIBLE } else { binding.messageInputView.messageSendButton.visibility = View.VISIBLE binding.messageInputView.recordAudioButton.visibility = View.GONE } } private fun setMessageAsDeleted(message: IMessage?) { val messageTemp = message as ChatMessage messageTemp.isDeleted = true messageTemp.isOneToOneConversation = currentConversation?.type == Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL messageTemp.activeUser = conversationUser adapter?.update(messageTemp) } private fun updateAdapterForReaction(message: IMessage?) { val messageTemp = message as ChatMessage messageTemp.isOneToOneConversation = currentConversation?.type == Conversation.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL messageTemp.activeUser = conversationUser adapter?.update(messageTemp) } fun updateAdapterAfterSendReaction(message: ChatMessage, emoji: String) { if (message.reactions == null) { message.reactions = LinkedHashMap() } if (message.reactionsSelf == null) { message.reactionsSelf = ArrayList() } var amount = message.reactions!![emoji] if (amount == null) { amount = 0 } message.reactions!![emoji] = amount + 1 message.reactionsSelf!!.add(emoji) adapter?.update(message) } private fun isShowMessageDeletionButton(message: ChatMessage): Boolean { if (conversationUser == null) return false val isUserAllowedByPrivileges = if (message.actorId == conversationUser.userId) { true } else { currentConversation!!.canModerate(conversationUser) } val isOlderThanSixHours = message .createdAt ?.before(Date(System.currentTimeMillis() - AGE_THREHOLD_FOR_DELETE_MESSAGE)) == true return when { !isUserAllowedByPrivileges -> false isOlderThanSixHours -> false message.systemMessageType != ChatMessage.SystemMessageType.DUMMY -> false message.isDeleted -> false message.hasFileAttachment() -> false OBJECT_MESSAGE == message.message -> false !CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "delete-messages") -> false !hasChatPermission -> false else -> true } } override fun hasContentFor(message: ChatMessage, type: Byte): Boolean { return when (type) { CONTENT_TYPE_LOCATION -> message.hasGeoLocation() CONTENT_TYPE_VOICE_MESSAGE -> message.isVoiceMessage CONTENT_TYPE_POLL -> message.isPoll() CONTENT_TYPE_SYSTEM_MESSAGE -> !TextUtils.isEmpty(message.systemMessage) CONTENT_TYPE_UNREAD_NOTICE_MESSAGE -> message.id == "-1" else -> 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 ) { var apiVersion = 1 // FIXME Fix API checking with guests? if (conversationUser != null) { apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.APIv4, 1)) } val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom( apiVersion, conversationUser?.baseUrl, "1", null, userMentionClickEvent.userId, null ) ncApi.createRoom( credentials, retrofitBucket.url, retrofitBucket.queryMap ) ?.subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { // unused atm } override fun onNext(roomOverall: RoomOverall) { val conversationIntent = Intent(activity, CallActivity::class.java) val bundle = Bundle() bundle.putParcelable(KEY_USER_ENTITY, conversationUser) bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token) bundle.putString(KEY_ROOM_ID, roomOverall.ocs!!.data!!.roomId) if (conversationUser != null) { bundle.putParcelable( 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() } }, POP_CURRENT_CONTROLLER_DELAY ) } } override fun onError(e: Throwable) { // unused atm } override fun onComplete() { // unused atm } }) } } fun sendPictureFromCamIntent() { if (!permissionUtil.isCameraPermissionGranted()) { requestCameraPermissions() } else { startActivityForResult(TakePhotoActivity.createIntent(context!!), REQUEST_CODE_PICK_CAMERA) } } fun createPoll() { val pollVoteDialog = PollCreateDialogFragment.newInstance( roomToken!! ) pollVoteDialog.show( (activity as MainActivity?)!!.supportFragmentManager, TAG ) } companion object { private const val TAG = "ChatController" private const val CONTENT_TYPE_SYSTEM_MESSAGE: Byte = 1 private const val CONTENT_TYPE_UNREAD_NOTICE_MESSAGE: Byte = 2 private const val CONTENT_TYPE_LOCATION: Byte = 3 private const val CONTENT_TYPE_VOICE_MESSAGE: Byte = 4 private const val CONTENT_TYPE_POLL: Byte = 5 private const val NEW_MESSAGES_POPUP_BUBBLE_DELAY: Long = 200 private const val POP_CURRENT_CONTROLLER_DELAY: Long = 100 private const val LOBBY_TIMER_DELAY: Long = 5000 private const val HTTP_CODE_OK: Int = 200 private const val MESSAGE_MAX_LENGTH: Int = 1000 private const val AGE_THREHOLD_FOR_DELETE_MESSAGE: Int = 21600000 // (6 hours in millis = 6 * 3600 * 1000) private const val REQUEST_CODE_CHOOSE_FILE: Int = 555 private const val REQUEST_CODE_SELECT_CONTACT: Int = 666 private const val REQUEST_CODE_MESSAGE_SEARCH: Int = 777 private const val REQUEST_RECORD_AUDIO_PERMISSION = 222 private const val REQUEST_READ_CONTACT_PERMISSION = 234 private const val REQUEST_CAMERA_PERMISSION = 223 private const val REQUEST_CODE_PICK_CAMERA: Int = 333 private const val REQUEST_CODE_SELECT_REMOTE_FILES = 888 private const val OBJECT_MESSAGE: String = "{object}" private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000 private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -50 private const val VOICE_MESSAGE_META_DATA = "{\"messageType\":\"voice-message\"}" private const val VOICE_MESSAGE_FILE_SUFFIX = ".mp3" private const val SHORT_VIBRATE: Long = 20 private const val FULLY_OPAQUE_INT: Int = 255 private const val SEMI_TRANSPARENT_INT: Int = 99 private const val VOICE_MESSAGE_SEEKBAR_BASE: Int = 1000 private const val SECOND: Long = 1000 private const val NO_PREVIOUS_MESSAGE_ID: Int = -1 private const val GROUPED_MESSAGES_THRESHOLD = 4 private const val GROUPED_MESSAGES_SAME_AUTHOR_THRESHOLD = 5 private const val TOOLBAR_AVATAR_RATIO = 1.5 private const val HTTP_CODE_NOT_MODIFIED = 304 private const val HTTP_CODE_PRECONDITION_FAILED = 412 private const val QUOTED_MESSAGE_IMAGE_MAX_HEIGHT = 96f private const val MENTION_AUTO_COMPLETE_ELEVATION = 6f private const val MESSAGE_PULL_LIMIT = 100 private const val INVITE_LENGTH = 6 private const val ACTOR_LENGTH = 6 private const val ANIMATION_DURATION: Long = 750 private const val RETRIES: Long = 3 private const val LOOKING_INTO_FUTURE_TIMEOUT = 30 private const val CHUNK_SIZE: Int = 10 } }