talk-android/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
sowjanyakch e72701c219
ktlintFormat
Signed-off-by: sowjanyakch <sowjanya.kch@gmail.com>
2025-05-22 15:15:59 +02:00

4194 lines
168 KiB
Kotlin

/*
* Nextcloud Talk - Android Client
*
* SPDX-FileCopyrightText: 2024 Christian Reiner <foss@christian-reiner.info>
* SPDX-FileCopyrightText: 2024 Parneet Singh <gurayaparneet@gmail.com>
* SPDX-FileCopyrightText: 2024 Giacomo Pacini <giacomo@paciosoft.com>
* SPDX-FileCopyrightText: 2023 Ezhil Shanmugham <ezhil56x.contact@gmail.com>
* SPDX-FileCopyrightText: 2021-2022 Marcel Hibbe <dev@mhibbe.de>
* SPDX-FileCopyrightText: 2021-2022 Tim Krüger <t@timkrueger.me>
* SPDX-FileCopyrightText: 2021 Andy Scherzinger <info@andy-scherzinger.de>
* SPDX-FileCopyrightText: 2017-2019 Mario Danic <mario@lovelyhq.com>
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.nextcloud.talk.chat
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
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.database.Cursor
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.provider.ContactsContract
import android.provider.MediaStore
import android.text.SpannableStringBuilder
import android.text.TextUtils
import android.util.Log
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.AbsListView
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.PopupWindow
import android.widget.TextView
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.ActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.view.ContextThemeWrapper
import androidx.cardview.widget.CardView
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.FileProvider
import androidx.core.content.PermissionChecker
import androidx.core.content.PermissionChecker.PERMISSION_GRANTED
import androidx.core.graphics.ColorUtils
import androidx.core.graphics.drawable.toBitmap
import androidx.core.graphics.drawable.toDrawable
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.core.text.bold
import androidx.emoji2.text.EmojiCompat
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.commit
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
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.imageLoader
import coil.load
import coil.request.CachePolicy
import coil.request.ImageRequest
import coil.target.Target
import coil.transform.CircleCropTransformation
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.nextcloud.android.common.ui.color.ColorUtil
import com.nextcloud.android.common.ui.theme.utils.ColorRole
import com.nextcloud.talk.BuildConfig
import com.nextcloud.talk.R
import com.nextcloud.talk.activities.BaseActivity
import com.nextcloud.talk.activities.CallActivity
import com.nextcloud.talk.activities.TakePhotoActivity
import com.nextcloud.talk.adapters.messages.CallStartedMessageInterface
import com.nextcloud.talk.adapters.messages.CommonMessageInterface
import com.nextcloud.talk.adapters.messages.IncomingDeckCardViewHolder
import com.nextcloud.talk.adapters.messages.IncomingLinkPreviewMessageViewHolder
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.IncomingTextMessageViewHolder
import com.nextcloud.talk.adapters.messages.IncomingVoiceMessageViewHolder
import com.nextcloud.talk.adapters.messages.MessagePayload
import com.nextcloud.talk.adapters.messages.OutcomingDeckCardViewHolder
import com.nextcloud.talk.adapters.messages.OutcomingLinkPreviewMessageViewHolder
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.OutcomingTextMessageViewHolder
import com.nextcloud.talk.adapters.messages.OutcomingVoiceMessageViewHolder
import com.nextcloud.talk.adapters.messages.PreviewMessageInterface
import com.nextcloud.talk.adapters.messages.PreviewMessageViewHolder
import com.nextcloud.talk.adapters.messages.SystemMessageInterface
import com.nextcloud.talk.adapters.messages.SystemMessageViewHolder
import com.nextcloud.talk.adapters.messages.TalkMessagesListAdapter
import com.nextcloud.talk.adapters.messages.UnreadNoticeMessageViewHolder
import com.nextcloud.talk.adapters.messages.VoiceMessageInterface
import com.nextcloud.talk.api.NcApi
import com.nextcloud.talk.application.NextcloudTalkApplication
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel
import com.nextcloud.talk.conversationinfo.ConversationInfoActivity
import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel
import com.nextcloud.talk.conversationlist.ConversationsListActivity
import com.nextcloud.talk.data.network.NetworkMonitor
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.databinding.ActivityChatBinding
import com.nextcloud.talk.events.UserMentionClickEvent
import com.nextcloud.talk.events.WebSocketCommunicationEvent
import com.nextcloud.talk.extensions.loadAvatarOrImagePreview
import com.nextcloud.talk.jobs.DeleteConversationWorker
import com.nextcloud.talk.jobs.DownloadFileToCacheWorker
import com.nextcloud.talk.jobs.ShareOperationWorker
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
import com.nextcloud.talk.location.LocationPickerActivity
import com.nextcloud.talk.messagesearch.MessageSearchActivity
import com.nextcloud.talk.models.ExternalSignalingServer
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.json.capabilities.SpreedCapability
import com.nextcloud.talk.models.json.chat.ReadStatus
import com.nextcloud.talk.models.json.conversations.ConversationEnums
import com.nextcloud.talk.models.json.participants.Participant
import com.nextcloud.talk.models.json.signaling.settings.SignalingSettingsOverall
import com.nextcloud.talk.polls.ui.PollCreateDialogFragment
import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity
import com.nextcloud.talk.shareditems.activities.SharedItemsActivity
import com.nextcloud.talk.signaling.SignalingMessageReceiver
import com.nextcloud.talk.signaling.SignalingMessageSender
import com.nextcloud.talk.translate.ui.TranslateActivity
import com.nextcloud.talk.ui.PlaybackSpeed
import com.nextcloud.talk.ui.PlaybackSpeedControl
import com.nextcloud.talk.ui.StatusDrawable
import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet
import com.nextcloud.talk.ui.dialog.ContextChatCompose
import com.nextcloud.talk.ui.dialog.DateTimeCompose
import com.nextcloud.talk.ui.dialog.FileAttachmentPreviewFragment
import com.nextcloud.talk.ui.dialog.MessageActionsDialog
import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment
import com.nextcloud.talk.ui.dialog.ShowReactionsDialog
import com.nextcloud.talk.ui.dialog.TempMessageActionsDialog
import com.nextcloud.talk.ui.recyclerview.MessageSwipeActions
import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback
import com.nextcloud.talk.utils.ApiUtils
import com.nextcloud.talk.utils.AudioUtils
import com.nextcloud.talk.utils.CapabilitiesUtil
import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
import com.nextcloud.talk.utils.CapabilitiesUtil.retentionOfEventRooms
import com.nextcloud.talk.utils.CapabilitiesUtil.retentionOfInstantMeetingRoom
import com.nextcloud.talk.utils.CapabilitiesUtil.retentionOfSIPRoom
import com.nextcloud.talk.utils.ContactUtils
import com.nextcloud.talk.utils.ConversationUtils
import com.nextcloud.talk.utils.DateConstants
import com.nextcloud.talk.utils.DateUtils
import com.nextcloud.talk.utils.DisplayUtils
import com.nextcloud.talk.utils.FileUtils
import com.nextcloud.talk.utils.FileViewerUtils
import com.nextcloud.talk.utils.Mimetype
import com.nextcloud.talk.utils.NotificationUtils
import com.nextcloud.talk.utils.ParticipantPermissions
import com.nextcloud.talk.utils.SpreedFeatures
import com.nextcloud.talk.utils.VibrationUtils
import com.nextcloud.talk.utils.bundle.BundleKeys
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY
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_IS_BREAKOUT_ROOM
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_IS_MODERATOR
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_RECORDING_STATE
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_START_CALL_AFTER_ROOM_SWITCH
import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SWITCH_TO_ROOM
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.webrtc.WebSocketConnectionHelper
import com.nextcloud.talk.webrtc.WebSocketInstance
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 io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.text.SimpleDateFormat
import java.time.Instant
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import java.util.Date
import java.util.Locale
import java.util.concurrent.ExecutionException
import javax.inject.Inject
import kotlin.math.roundToInt
@Suppress("TooManyFunctions")
@AutoInjector(NextcloudTalkApplication::class)
class ChatActivity :
BaseActivity(),
MessagesListAdapter.OnLoadMoreListener,
MessagesListAdapter.Formatter<Date>,
MessagesListAdapter.OnMessageViewLongClickListener<IMessage>,
ContentChecker<ChatMessage>,
VoiceMessageInterface,
CommonMessageInterface,
PreviewMessageInterface,
SystemMessageInterface,
CallStartedMessageInterface {
var active = false
private lateinit var binding: ActivityChatBinding
@Inject
lateinit var ncApi: NcApi
@Inject
lateinit var permissionUtil: PlatformPermissionUtil
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var colorUtil: ColorUtil
@Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
@Inject
lateinit var networkMonitor: NetworkMonitor
lateinit var chatViewModel: ChatViewModel
lateinit var conversationInfoViewModel: ConversationInfoViewModel
lateinit var messageInputViewModel: MessageInputViewModel
private val startSelectContactForResult = registerForActivityResult(
ActivityResultContracts
.StartActivityForResult()
) {
executeIfResultOk(it) { intent ->
onSelectContactResult(intent)
}
}
private val startChooseFileIntentForResult = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
executeIfResultOk(it) { intent ->
onChooseFileResult(intent)
}
}
private val startRemoteFileBrowsingForResult = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
executeIfResultOk(it) { intent ->
onRemoteFileBrowsingResult(intent)
}
}
private val startMessageSearchForResult =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
executeIfResultOk(it) { intent ->
runBlocking {
val id = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_MESSAGE_ID)
id?.let {
val isSaved = chatViewModel.isMessageSaved(id.toLong())
if (isSaved) {
onMessageSearchResult(intent)
} else {
startContextChatWindowForMessage(id)
}
}
}
}
}
private fun startContextChatWindowForMessage(id: String?) {
binding.genericComposeView.apply {
val shouldDismiss = mutableStateOf(false)
setContent {
val bundle = bundleOf()
bundle.putString(BundleKeys.KEY_CREDENTIALS, credentials!!)
bundle.putString(BundleKeys.KEY_BASE_URL, conversationUser!!.baseUrl)
bundle.putString(KEY_ROOM_TOKEN, roomToken)
bundle.putString(BundleKeys.KEY_MESSAGE_ID, id)
bundle.putString(
KEY_CONVERSATION_NAME,
currentConversation!!.displayName
)
ContextChatCompose(bundle).GetDialogView(shouldDismiss, context)
}
}
Log.d(TAG, "Should open something else")
}
private val startPickCameraIntentForResult = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
executeIfResultOk(it) { intent ->
onPickCameraResult(intent)
}
}
override val view: View
get() = binding.root
val disposables = DisposableSet()
var sessionIdAfterRoomJoined: String? = null
lateinit var roomToken: String
var conversationUser: User? = null
lateinit var spreedCapabilities: SpreedCapability
var chatApiVersion: Int = 1
private var roomPassword: String = ""
var credentials: String? = null
var currentConversation: ConversationModel? = null
var adapter: TalkMessagesListAdapter<ChatMessage>? = null
var mentionAutocomplete: Autocomplete<*>? = null
var layoutManager: LinearLayoutManager? = null
var pullChatMessagesPending = false
var startCallFromNotification: Boolean = false
var startCallFromRoomSwitch: Boolean = false
var voiceOnly: Boolean = true
private lateinit var path: String
var myFirstMessage: CharSequence? = null
var checkingLobbyStatus: Boolean = false
private var conversationVoiceCallMenuItem: MenuItem? = null
private var conversationVideoMenuItem: MenuItem? = null
private var eventConversationMenuItem: MenuItem? = null
var webSocketInstance: WebSocketInstance? = null
var signalingMessageSender: SignalingMessageSender? = null
var externalSignalingServer: ExternalSignalingServer? = null
var getRoomInfoTimerHandler: Handler? = null
private val filesToUpload: MutableList<String> = ArrayList()
lateinit var sharedText: String
lateinit var participantPermissions: ParticipantPermissions
private var videoURI: Uri? = null
private lateinit var pickMultipleMedia: ActivityResultLauncher<PickVisualMediaRequest>
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val intent = Intent(this@ChatActivity, ConversationsListActivity::class.java)
intent.putExtras(Bundle())
startActivity(intent)
}
}
private lateinit var messageInputFragment: MessageInputFragment
val typingParticipants = HashMap<String, TypingParticipant>()
var callStarted = false
private val localParticipantMessageListener = object : SignalingMessageReceiver.LocalParticipantMessageListener {
override fun onSwitchTo(token: String?) {
if (token != null) {
if (CallActivity.active) {
Log.d(TAG, "CallActivity is running. Ignore to switch chat in ChatActivity...")
} else {
switchToRoom(token, false, false)
}
}
}
}
private val conversationMessageListener = object : SignalingMessageReceiver.ConversationMessageListener {
override fun onStartTyping(userId: String?, session: String?) {
val userIdOrGuestSession = userId ?: session
if (isTypingStatusEnabled() && conversationUser?.userId != userIdOrGuestSession) {
var displayName = webSocketInstance?.getDisplayNameForSession(session)
if (displayName != null && !typingParticipants.contains(userIdOrGuestSession)) {
if (displayName == "") {
displayName = context.resources?.getString(R.string.nc_guest)!!
}
runOnUiThread {
val typingParticipant = TypingParticipant(userIdOrGuestSession!!, displayName) {
typingParticipants.remove(userIdOrGuestSession)
updateTypingIndicator()
}
typingParticipants[userIdOrGuestSession] = typingParticipant
updateTypingIndicator()
}
} else if (typingParticipants.contains(userIdOrGuestSession)) {
typingParticipants[userIdOrGuestSession]?.restartTimer()
}
}
}
override fun onStopTyping(userId: String?, session: String?) {
val userIdOrGuestSession = userId ?: session
if (isTypingStatusEnabled() && conversationUser?.userId != userId) {
typingParticipants[userIdOrGuestSession]?.cancelTimer()
typingParticipants.remove(userIdOrGuestSession)
updateTypingIndicator()
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this)
binding = ActivityChatBinding.inflate(layoutInflater)
setupActionBar()
setContentView(binding.root)
setupSystemColors()
conversationUser = currentUserProvider.currentUser.blockingGet()
handleIntent(intent)
chatViewModel = ViewModelProvider(this, viewModelFactory)[ChatViewModel::class.java]
conversationInfoViewModel = ViewModelProvider(this, viewModelFactory)[ConversationInfoViewModel::class.java]
val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken)
val credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token)
chatViewModel.initData(
credentials!!,
urlForChatting,
roomToken
)
messageInputFragment = getMessageInputFragment()
messageInputViewModel = ViewModelProvider(this, viewModelFactory)[MessageInputViewModel::class.java]
messageInputViewModel.setData(chatViewModel.getChatRepository())
binding.progressBar.visibility = View.VISIBLE
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
initObservers()
pickMultipleMedia = registerForActivityResult(
ActivityResultContracts.PickMultipleVisualMedia(5)
) { uris ->
if (uris.isNotEmpty()) {
onChooseFileResult(uris)
}
}
}
private fun getMessageInputFragment(): MessageInputFragment {
val internalId = conversationUser!!.id.toString() + "@" + roomToken
return MessageInputFragment().apply {
arguments = Bundle().apply {
putString(CONVERSATION_INTERNAL_ID, internalId)
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
val extras: Bundle? = intent.extras
val requestedRoomSwitch = extras?.getBoolean(KEY_SWITCH_TO_ROOM, false) == true
if (requestedRoomSwitch) {
val newRoomToken = extras?.getString(KEY_ROOM_TOKEN).orEmpty()
val startCallAfterRoomSwitch = extras?.getBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, false) == true
val isVoiceOnlyCall = extras?.getBoolean(KEY_CALL_VOICE_ONLY, false) == true
if (newRoomToken != roomToken) {
switchToRoom(newRoomToken, startCallAfterRoomSwitch, isVoiceOnlyCall)
}
} else {
handleIntent(intent)
}
}
private fun handleIntent(intent: Intent) {
val extras: Bundle? = intent.extras
roomToken = extras?.getString(KEY_ROOM_TOKEN).orEmpty()
sharedText = extras?.getString(BundleKeys.KEY_SHARED_TEXT).orEmpty()
Log.d(TAG, " roomToken = $roomToken")
if (roomToken.isEmpty()) {
Log.d(TAG, " roomToken was null or empty!")
}
roomPassword = extras?.getString(BundleKeys.KEY_CONVERSATION_PASSWORD).orEmpty()
credentials = if (conversationUser?.userId == "?") {
null
} else {
ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token)
}
startCallFromNotification = extras?.getBoolean(BundleKeys.KEY_FROM_NOTIFICATION_START_CALL, false) == true
startCallFromRoomSwitch = extras?.getBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, false) == true
voiceOnly = extras?.getBoolean(KEY_CALL_VOICE_ONLY, false) == true
}
override fun onStart() {
super.onStart()
active = true
this.lifecycle.addObserver(AudioUtils)
this.lifecycle.addObserver(chatViewModel)
}
override fun onSaveInstanceState(outState: Bundle) {
chatViewModel.handleOrientationChange()
super.onSaveInstanceState(outState)
}
override fun onStop() {
super.onStop()
active = false
adapter = null
this.lifecycle.removeObserver(AudioUtils)
this.lifecycle.removeObserver(chatViewModel)
}
@SuppressLint("NotifyDataSetChanged", "SetTextI18n", "ResourceAsColor")
@Suppress("LongMethod")
private fun initObservers() {
Log.d(TAG, "initObservers Called")
this.lifecycleScope.launch {
chatViewModel.getConversationFlow
.onEach { conversationModel ->
currentConversation = conversationModel
chatViewModel.updateConversation(
currentConversation!!
)
logConversationInfos("GetRoomSuccessState")
if (adapter == null) {
initAdapter()
binding.messagesListView.setAdapter(adapter)
layoutManager = binding.messagesListView.layoutManager as LinearLayoutManager?
}
chatViewModel.getCapabilities(conversationUser!!, roomToken, currentConversation!!)
}.collect()
}
chatViewModel.getRoomViewState.observe(this) { state ->
when (state) {
is ChatViewModel.GetRoomSuccessState -> {
// unused atm
}
is ChatViewModel.GetRoomErrorState -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
else -> {}
}
}
chatViewModel.getCapabilitiesViewState.observe(this) { state ->
when (state) {
is ChatViewModel.GetCapabilitiesUpdateState -> {
if (currentConversation != null) {
spreedCapabilities = state.spreedCapabilities
chatApiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1))
participantPermissions = ParticipantPermissions(spreedCapabilities, currentConversation!!)
invalidateOptionsMenu()
isEventConversation()
checkShowCallButtons()
checkLobbyState()
updateRoomTimerHandler()
} else {
Log.w(
TAG,
"currentConversation was null in observer ChatViewModel.GetCapabilitiesUpdateState"
)
}
}
is ChatViewModel.GetCapabilitiesInitialLoadState -> {
if (currentConversation != null) {
spreedCapabilities = state.spreedCapabilities
chatApiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1))
participantPermissions = ParticipantPermissions(spreedCapabilities, currentConversation!!)
supportFragmentManager.commit {
setReorderingAllowed(true) // optimizes out redundant replace operations
replace(R.id.fragment_container_activity_chat, messageInputFragment)
}
joinRoomWithPassword()
if (conversationUser?.userId != "?" &&
CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG)
) {
binding.chatToolbar.setOnClickListener { _ -> showConversationInfoScreen() }
}
loadAvatarForStatusBar()
setupSwipeToReply()
setActionBarTitle()
isEventConversation()
checkShowCallButtons()
checkLobbyState()
if (currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL &&
currentConversation?.status == "dnd"
) {
conversationUser?.let { user ->
val credentials = ApiUtils.getCredentials(user.username, user.token)
chatViewModel.outOfOfficeStatusOfUser(
credentials!!,
user.baseUrl!!,
currentConversation!!.name
)
}
}
if (currentConversation?.objectType == ConversationEnums.ObjectType.EVENT &&
hasSpreedFeatureCapability(
conversationUser?.capabilities!!.spreedCapability!!,
SpreedFeatures.UNBIND_CONVERSATION
)
) {
val eventEndTimeStamp = currentConversation?.objectId?.split("#")[1]?.toLong()
val currentTimeStamp = (System.currentTimeMillis() / 1000).toLong()
val retentionPeriod = retentionOfEventRooms(spreedCapabilities)
val isPastEvent = eventEndTimeStamp?.let { it < currentTimeStamp }
if (isPastEvent == true && retentionPeriod != 0) {
showConversationDeletionWarning(retentionPeriod)
}
}
if (currentConversation?.objectType == ConversationEnums.ObjectType.PHONE &&
hasSpreedFeatureCapability(
conversationUser?.capabilities!!.spreedCapability!!,
SpreedFeatures.UNBIND_CONVERSATION
)
) {
val retentionPeriod = retentionOfSIPRoom(spreedCapabilities)
val systemMessage = currentConversation?.lastMessage?.systemMessageType
if (retentionPeriod != 0 && (
systemMessage == ChatMessage.SystemMessageType.CALL_ENDED ||
systemMessage == ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE
)
) {
showConversationDeletionWarning(retentionPeriod)
}
}
if (currentConversation?.objectType == ConversationEnums.ObjectType.INSTANT_MEETING &&
hasSpreedFeatureCapability(
conversationUser?.capabilities!!.spreedCapability!!,
SpreedFeatures.UNBIND_CONVERSATION
)
) {
val retentionPeriod = retentionOfInstantMeetingRoom(spreedCapabilities)
val systemMessage = currentConversation?.lastMessage?.systemMessageType
if (retentionPeriod != 0 && (
systemMessage == ChatMessage.SystemMessageType.CALL_ENDED ||
systemMessage == ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE
)
) {
showConversationDeletionWarning(retentionPeriod)
}
}
updateRoomTimerHandler(MILLIS_250)
val urlForChatting =
ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken)
chatViewModel.loadMessages(
withCredentials = credentials!!,
withUrl = urlForChatting
)
} else {
Log.w(
TAG,
"currentConversation was null in observer ChatViewModel.GetCapabilitiesInitialLoadState"
)
}
}
is ChatViewModel.GetCapabilitiesErrorState -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
else -> {}
}
}
chatViewModel.joinRoomViewState.observe(this) { state ->
when (state) {
is ChatViewModel.JoinRoomSuccessState -> {
currentConversation = state.conversationModel
sessionIdAfterRoomJoined = currentConversation!!.sessionId
ApplicationWideCurrentRoomHolder.getInstance().session = currentConversation!!.sessionId
ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = currentConversation!!.token
ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser
logConversationInfos("joinRoomWithPassword#onNext")
setupWebsocket()
if (startCallFromNotification) {
startCallFromNotification = false
startACall(voiceOnly, false)
}
if (startCallFromRoomSwitch) {
startCallFromRoomSwitch = false
startACall(voiceOnly, true)
}
}
is ChatViewModel.JoinRoomErrorState -> {}
else -> {}
}
}
chatViewModel.leaveRoomViewState.observe(this) { state ->
when (state) {
is ChatViewModel.LeaveRoomSuccessState -> {
logConversationInfos("leaveRoom#onNext")
checkingLobbyStatus = false
if (getRoomInfoTimerHandler != null) {
getRoomInfoTimerHandler?.removeCallbacksAndMessages(null)
}
if (webSocketInstance != null && currentConversation != null) {
webSocketInstance?.joinRoomWithRoomTokenAndSession(
"",
sessionIdAfterRoomJoined
)
}
sessionIdAfterRoomJoined = "0"
if (state.funToCallWhenLeaveSuccessful != null) {
Log.d(TAG, "a callback action was set and is now executed because room was left successfully")
state.funToCallWhenLeaveSuccessful.invoke()
}
}
else -> {}
}
}
messageInputViewModel.sendChatMessageViewState.observe(this) { state ->
when (state) {
is MessageInputViewModel.SendChatMessageSuccessState -> {
myFirstMessage = state.message
removeUnreadMessagesMarker()
if (binding.unreadMessagesPopup.isShown) {
binding.unreadMessagesPopup.visibility = View.GONE
}
binding.messagesListView.smoothScrollToPosition(0)
}
is MessageInputViewModel.SendChatMessageErrorState -> {
binding.messagesListView.smoothScrollToPosition(0)
}
else -> {}
}
}
chatViewModel.deleteChatMessageViewState.observe(this) { state ->
when (state) {
is ChatViewModel.DeleteChatMessageSuccessState -> {
if (state.msg.ocs!!.meta!!.statusCode == HttpURLConnection.HTTP_ACCEPTED) {
Snackbar.make(
binding.root,
R.string.nc_delete_message_leaked_to_matterbridge,
Snackbar.LENGTH_LONG
).show()
}
val id = state.msg.ocs!!.data!!.parentMessage!!.id.toString()
val index = adapter?.getMessagePositionById(id) ?: 0
val message = adapter?.items?.get(index)?.item as ChatMessage
setMessageAsDeleted(message)
}
is ChatViewModel.DeleteChatMessageErrorState -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
else -> {}
}
}
chatViewModel.createRoomViewState.observe(this) { state ->
when (state) {
is ChatViewModel.CreateRoomSuccessState -> {
val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, state.roomOverall.ocs!!.data!!.token)
leaveRoom {
val chatIntent = Intent(context, ChatActivity::class.java)
chatIntent.putExtras(bundle)
chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(chatIntent)
}
}
is ChatViewModel.CreateRoomErrorState -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
else -> {}
}
}
chatViewModel.chatMessageViewState.observe(this) { state ->
when (state) {
is ChatViewModel.ChatMessageStartState -> {
// Handle UI on first load
cancelNotificationsForCurrentConversation()
binding.progressBar.visibility = View.GONE
binding.offline.root.visibility = View.GONE
binding.messagesListView.visibility = View.VISIBLE
collapseSystemMessages()
}
is ChatViewModel.ChatMessageUpdateState -> {
binding.progressBar.visibility = View.GONE
binding.offline.root.visibility = View.GONE
binding.messagesListView.visibility = View.VISIBLE
}
is ChatViewModel.ChatMessageErrorState -> {
// unused atm
}
else -> {}
}
}
this.lifecycleScope.launch {
chatViewModel.getMessageFlow
.onEach { triple ->
val lookIntoFuture = triple.first
val setUnreadMessagesMarker = triple.second
var chatMessageList = triple.third
chatMessageList = handleSystemMessages(chatMessageList)
if (chatMessageList.isEmpty()) {
return@onEach
}
determinePreviousMessageIds(chatMessageList)
handleExpandableSystemMessages(chatMessageList)
if (ChatMessage.SystemMessageType.CLEARED_CHAT == chatMessageList[0].systemMessageType) {
adapter?.clear()
adapter?.notifyDataSetChanged()
}
if (lookIntoFuture) {
Log.d(TAG, "chatMessageList.size in getMessageFlow:" + chatMessageList.size)
processMessagesFromTheFuture(chatMessageList, setUnreadMessagesMarker)
} else {
processMessagesNotFromTheFuture(chatMessageList)
collapseSystemMessages()
}
processExpiredMessages()
processCallStartedMessages()
adapter?.notifyDataSetChanged()
}
.collect()
}
this.lifecycleScope.launch {
chatViewModel.getRemoveMessageFlow
.onEach {
removeMessageById(it.id)
}
.collect()
}
this.lifecycleScope.launch {
chatViewModel.getUpdateMessageFlow
.onEach {
updateMessageInsideAdapter(it)
}
.collect()
}
this.lifecycleScope.launch {
chatViewModel.getLastCommonReadFlow
.onEach {
updateReadStatusOfAllMessages(it)
processExpiredMessages()
}
.collect()
}
this.lifecycleScope.launch {
chatViewModel.getLastReadMessageFlow
.onEach { lastRead ->
scrollToAndCenterMessageWithId(lastRead.toString())
}
.collect()
}
this.lifecycleScope.launch {
chatViewModel.getGeneralUIFlow.onEach { key ->
when (key) {
NO_OFFLINE_MESSAGES_FOUND -> {
binding.progressBar.visibility = View.GONE
binding.messagesListView.visibility = View.GONE
binding.offline.root.visibility = View.VISIBLE
}
else -> {}
}
}.collect()
}
this.lifecycleScope.launch {
chatViewModel.mediaPlayerSeekbarObserver.onEach { msg ->
adapter?.update(msg)
}.collect()
}
chatViewModel.reactionDeletedViewState.observe(this) { state ->
when (state) {
is ChatViewModel.ReactionDeletedSuccessState -> {
updateUiToDeleteReaction(
state.reactionDeletedModel.chatMessage,
state.reactionDeletedModel.emoji
)
}
else -> {}
}
}
chatViewModel.reactionAddedViewState.observe(this) { state ->
when (state) {
is ChatViewModel.ReactionAddedSuccessState -> {
updateUiToAddReaction(
state.reactionAddedModel.chatMessage,
state.reactionAddedModel.emoji
)
}
else -> {}
}
}
messageInputViewModel.editMessageViewState.observe(this) { state ->
when (state) {
is MessageInputViewModel.EditMessageSuccessState -> {
when (state.messageEdited.ocs?.meta?.statusCode) {
HTTP_BAD_REQUEST -> {
Snackbar.make(
binding.root,
getString(R.string.edit_error_24_hours_old_message),
Snackbar.LENGTH_LONG
).show()
}
HTTP_FORBIDDEN -> {
Snackbar.make(
binding.root,
getString(R.string.conversation_is_read_only),
Snackbar.LENGTH_LONG
).show()
}
HTTP_NOT_FOUND -> {
Snackbar.make(
binding.root,
"Conversation not found",
Snackbar.LENGTH_LONG
).show()
}
}
val newString = state.messageEdited.ocs?.data?.parentMessage?.message ?: "(null)"
val id = state.messageEdited.ocs?.data?.parentMessage?.id.toString()
val index = adapter?.getMessagePositionById(id) ?: 0
val item = adapter?.items?.get(index)?.item
item?.let {
setMessageAsEdited(item as ChatMessage, newString)
}
}
is MessageInputViewModel.EditMessageErrorState -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
else -> {}
}
}
chatViewModel.getVoiceRecordingLocked.observe(this) { showContiniousVoiceRecording ->
if (showContiniousVoiceRecording) {
binding.voiceRecordingLock.visibility = View.GONE
supportFragmentManager.commit {
setReorderingAllowed(true) // apparently used for optimizations
replace(R.id.fragment_container_activity_chat, MessageInputVoiceRecordingFragment())
}
} else {
supportFragmentManager.commit {
setReorderingAllowed(true)
replace(R.id.fragment_container_activity_chat, getMessageInputFragment())
}
}
}
chatViewModel.getVoiceRecordingInProgress.observe(this) { voiceRecordingInProgress ->
VibrationUtils.vibrateShort(context)
binding.voiceRecordingLock.visibility = if (
voiceRecordingInProgress &&
chatViewModel.getVoiceRecordingLocked.value != true
) {
View.VISIBLE
} else {
View.GONE
}
}
chatViewModel.recordTouchObserver.observe(this) { y ->
binding.voiceRecordingLock.y -= y
}
chatViewModel.unbindRoomResult.observe(this) { uiState ->
when (uiState) {
is ChatViewModel.UnbindRoomUiState.Success -> {
binding.conversationDeleteNotice.visibility = View.GONE
Snackbar.make(
binding.root,
context.getString(R.string.nc_room_retention),
Snackbar.LENGTH_LONG
).show()
}
is ChatViewModel.UnbindRoomUiState.Error -> {
Snackbar.make(
binding.root,
context.getString(R.string.nc_common_error_sorry),
Snackbar.LENGTH_LONG
).show()
}
else -> { }
}
}
chatViewModel.outOfOfficeViewState.observe(this) { uiState ->
when (uiState) {
is ChatViewModel.OutOfOfficeUIState.Error -> {
Log.e(TAG, "Error fetching/ no user absence data", uiState.exception)
}
ChatViewModel.OutOfOfficeUIState.None -> {
}
is ChatViewModel.OutOfOfficeUIState.Success -> {
binding.outOfOfficeContainer.visibility = View.VISIBLE
val backgroundColor = colorUtil.getNullSafeColorWithFallbackRes(
conversationUser!!.capabilities!!.themingCapability!!.color,
R.color.colorPrimary
)
binding.outOfOfficeContainer.findViewById<View>(
R.id.verticalLine
).setBackgroundColor(backgroundColor)
val setAlpha = ColorUtils.setAlphaComponent(backgroundColor, OUT_OF_OFFICE_ALPHA)
binding.outOfOfficeContainer.setCardBackgroundColor(setAlpha)
val startDateTimestamp: Long = uiState.userAbsence.startDate.toLong()
val endDateTimestamp: Long = uiState.userAbsence.endDate.toLong()
val startDate = Date(startDateTimestamp * ONE_SECOND_IN_MILLIS)
val endDate = Date(endDateTimestamp * ONE_SECOND_IN_MILLIS)
if (dateUtils.isSameDate(startDate, endDate)) {
binding.outOfOfficeContainer.findViewById<TextView>(R.id.userAbsenceShortMessage).text =
String.format(
context.resources.getString(R.string.user_absence_for_one_day),
currentConversation?.displayName
)
binding.outOfOfficeContainer.findViewById<TextView>(R.id.userAbsencePeriod).visibility =
View.GONE
} else {
val dateFormatter = SimpleDateFormat("MMM d, yyyy", Locale.getDefault())
val startDateString = dateFormatter.format(startDate)
val endDateString = dateFormatter.format(endDate)
binding.outOfOfficeContainer.findViewById<TextView>(R.id.userAbsenceShortMessage).text =
String.format(
context.resources.getString(R.string.user_absence),
currentConversation?.displayName
)
binding.outOfOfficeContainer.findViewById<TextView>(R.id.userAbsencePeriod).text =
"$startDateString - $endDateString"
}
if (uiState.userAbsence.replacementUserDisplayName != null) {
var imageUri = ApiUtils.getUrlForAvatar(
conversationUser?.baseUrl,
uiState.userAbsence
.replacementUserId,
false
).toUri()
if (DisplayUtils.isDarkModeOn(context)) {
imageUri = ApiUtils.getUrlForAvatarDarkTheme(
conversationUser?.baseUrl,
uiState
.userAbsence
.replacementUserId,
false
).toUri()
}
binding.outOfOfficeContainer.findViewById<TextView>(R.id.absenceReplacement).text =
context.resources.getString(R.string.user_absence_replacement)
binding.outOfOfficeContainer.findViewById<ImageView>(R.id.replacement_user_avatar)
.load(imageUri) {
transformations(CircleCropTransformation())
placeholder(R.drawable.account_circle_96dp)
error(R.drawable.account_circle_96dp)
crossfade(true)
}
binding.outOfOfficeContainer.findViewById<TextView>(R.id.replacement_user_name).text =
uiState.userAbsence.replacementUserDisplayName
} else {
binding.outOfOfficeContainer.findViewById<LinearLayout>(R.id.userAbsenceReplacement)
.visibility = View.GONE
}
binding.outOfOfficeContainer.findViewById<TextView>(R.id.userAbsenceLongMessage).text =
uiState.userAbsence.message
binding.outOfOfficeContainer.findViewById<CardView>(R.id.avatar_chip).setOnClickListener {
joinOneToOneConversation(uiState.userAbsence.replacementUserId!!)
}
}
}
}
}
private fun removeUnreadMessagesMarker() {
removeMessageById(UNREAD_MESSAGES_MARKER_ID.toString())
}
// do not use adapter.deleteById() as it seems to contain a bug! Use this method instead!
@Suppress("MagicNumber")
private fun removeMessageById(idToDelete: String) {
val indexToDelete = adapter?.getMessagePositionById(idToDelete)
if (indexToDelete != null && indexToDelete != UNREAD_MESSAGES_MARKER_ID) {
// If user sent a message as a first message in todays chat, the temp message will be deleted when
// messages are retrieved from server, but also the date has to be deleted as it will be added again
// when the chat messages are added from server. Otherwise date "Today" would be shown twice.
if (indexToDelete == 0 && (adapter?.items?.get(1))?.item is Date) {
adapter?.items?.removeAt(0)
adapter?.items?.removeAt(0)
adapter?.notifyItemRangeRemoved(indexToDelete, 1)
} else {
adapter?.items?.removeAt(indexToDelete)
adapter?.notifyItemRemoved(indexToDelete)
}
}
}
fun showConversationDeletionWarning(retentionPeriod: Int) {
binding.conversationDeleteNotice.visibility = View.VISIBLE
binding.conversationDeleteNotice.apply {
isClickable = false
isFocusable = false
bringToFront()
}
val deleteNoticeText = binding.conversationDeleteNotice.findViewById<TextView>(R.id.deletion_message)
if (currentConversation?.objectType != ConversationEnums.ObjectType.INSTANT_MEETING) {
deleteNoticeText.text = String.format(
resources.getString(R.string.nc_conversation_auto_delete_notice),
retentionPeriod
)
} else {
deleteNoticeText.text = String.format(
resources.getString(R.string.nc_conversation_auto_delete_warning),
retentionPeriod
)
}
if (ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!)) {
binding.conversationDeleteNotice.findViewById<MaterialButton>(R.id.delete_now_button).visibility =
View.VISIBLE
binding.conversationDeleteNotice.findViewById<MaterialButton>(R.id.keep_button).visibility = View.VISIBLE
} else {
binding.conversationDeleteNotice.findViewById<MaterialButton>(R.id.delete_now_button).visibility =
View.GONE
binding.conversationDeleteNotice.findViewById<MaterialButton>(R.id.keep_button).visibility = View.GONE
}
binding.conversationDeleteNotice.findViewById<MaterialButton>(R.id.delete_now_button).setOnClickListener {
deleteConversationDialog(it.context)
}
binding.conversationDeleteNotice.findViewById<MaterialButton>(R.id.keep_button).setOnClickListener {
chatViewModel.unbindRoom(credentials!!, conversationUser?.baseUrl!!, currentConversation?.token!!)
}
}
fun deleteConversationDialog(context: Context) {
val dialogBuilder = MaterialAlertDialogBuilder(context)
.setIcon(
viewThemeUtils.dialog
.colorMaterialAlertDialogIcon(context, R.drawable.ic_delete_black_24dp)
)
.setTitle(R.string.nc_delete_call)
.setMessage(R.string.nc_delete_conversation_more)
.setPositiveButton(R.string.nc_delete) { _, _ ->
currentConversation?.let { conversation ->
deleteConversation(conversation)
}
}
.setNegativeButton(R.string.nc_cancel) { _, _ ->
}
viewThemeUtils.dialog
.colorMaterialAlertDialogBackground(context, dialogBuilder)
val dialog = dialogBuilder.show()
viewThemeUtils.platform.colorTextButtons(
dialog.getButton(AlertDialog.BUTTON_POSITIVE),
dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
)
}
@Suppress("Detekt.TooGenericExceptionCaught")
override fun onResume() {
super.onResume()
logConversationInfos("onResume")
pullChatMessagesPending = false
webSocketInstance?.getSignalingMessageReceiver()?.addListener(localParticipantMessageListener)
webSocketInstance?.getSignalingMessageReceiver()?.addListener(conversationMessageListener)
cancelNotificationsForCurrentConversation()
chatViewModel.getRoom(roomToken)
actionBar?.show()
setupSwipeToReply()
binding.unreadMessagesPopup.setOnClickListener {
binding.messagesListView.smoothScrollToPosition(0)
binding.unreadMessagesPopup.visibility = View.GONE
}
binding.scrollDownButton.setOnClickListener {
binding.messagesListView.scrollToPosition(0)
it.visibility = View.GONE
}
binding.let { viewThemeUtils.material.colorMaterialButtonPrimaryTonal(it.scrollDownButton) }
binding.let { viewThemeUtils.material.themeFAB(it.voiceRecordingLock) }
binding.let { viewThemeUtils.material.colorMaterialButtonPrimaryFilled(it.unreadMessagesPopup) }
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 (isScrolledToBottom()) {
binding.unreadMessagesPopup.visibility = View.GONE
binding.scrollDownButton.visibility = View.GONE
} else {
if (binding.unreadMessagesPopup.isShown) {
binding.scrollDownButton.visibility = View.GONE
} else {
binding.scrollDownButton.visibility = View.VISIBLE
}
}
}
}
})
loadAvatarForStatusBar()
setActionBarTitle()
viewThemeUtils.material.colorToolbarOverflowIcon(binding.chatToolbar)
}
// private fun getLastAdapterId(): Int {
// var lastId = 0
// if (adapter?.items?.size != 0) {
// val item = adapter?.items?.get(0)?.item
// if (item != null) {
// lastId = (item as ChatMessage).jsonMessageId
// } else {
// lastId = 0
// }
// }
// return lastId
// }
private fun setupActionBar() {
setSupportActionBar(binding.chatToolbar)
binding.chatToolbar.setNavigationOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowHomeEnabled(true)
supportActionBar?.setIcon(resources!!.getColor(R.color.transparent, null).toDrawable())
setActionBarTitle()
viewThemeUtils.material.themeToolbar(binding.chatToolbar)
}
private fun initAdapter() {
val senderId = if (!conversationUser!!.userId.equals("?")) {
"users/" + conversationUser!!.userId
} else {
currentConversation?.actorType + "/" + currentConversation?.actorId
}
Log.d(TAG, "Initialize TalkMessagesListAdapter with senderId: $senderId")
adapter = TalkMessagesListAdapter(
senderId,
initMessageHolders(),
ImageLoader { imageView, url, placeholder ->
imageView.loadAvatarOrImagePreview(url!!, conversationUser!!, placeholder as Drawable?)
},
this
)
adapter?.setLoadMoreListener(this)
adapter?.setDateHeadersFormatter { format(it) }
adapter?.setOnMessageViewLongClickListener { view, message -> onMessageViewLongClick(view, message) }
adapter?.registerViewClickListener(
R.id.playPauseBtn
) { _, message ->
val filename = message.selectedIndividualHashMap!!["name"]
val file = File(context.cacheDir, filename!!)
if (file.exists()) {
if (message.isPlayingVoiceMessage) {
chatViewModel.pauseMediaPlayer(true)
message.isPlayingVoiceMessage = false
adapter?.update(message)
} else {
val retrieved = appPreferences.getWaveFormFromFile(filename)
if (retrieved.isEmpty()) {
setUpWaveform(message)
} else {
startPlayback(file, message)
}
}
} else {
Log.d(TAG, "Downloaded to cache")
downloadFileToCache(message, true) {
setUpWaveform(message)
}
}
}
adapter?.registerViewClickListener(R.id.playbackSpeedControlBtn) { button, message ->
val nextSpeed = (button as PlaybackSpeedControl).getSpeed().next()
chatViewModel.setPlayBack(nextSpeed)
appPreferences.savePreferredPlayback(conversationUser!!.userId, nextSpeed)
}
}
private fun setUpWaveform(message: ChatMessage, thenPlay: Boolean = true, backgroundPlayAllowed: Boolean = false) {
val filename = message.selectedIndividualHashMap!!["name"]
val file = File(context.cacheDir, filename!!)
if (file.exists() && message.voiceMessageFloatArray == null) {
message.isDownloadingVoiceMessage = true
adapter?.update(message)
CoroutineScope(Dispatchers.Default).launch {
val r = AudioUtils.audioFileToFloatArray(file)
appPreferences.saveWaveFormForFile(filename, r.toTypedArray())
message.voiceMessageFloatArray = r
withContext(Dispatchers.Main) {
startPlayback(file, message)
}
}
} else {
startPlayback(file, message)
}
}
private fun startPlayback(file: File, message: ChatMessage) {
chatViewModel.clearMediaPlayerQueue()
chatViewModel.queueInMediaPlayer(file.canonicalPath, message)
chatViewModel.startCyclingMediaPlayer()
message.isPlayingVoiceMessage = true
adapter?.update(message)
var pos = adapter?.getMessagePositionById(message.id)!! - 1
do {
if (pos < 0) break
val nextItem = (adapter?.items?.get(pos)?.item) ?: break
val nextMessage = if (nextItem is ChatMessage) nextItem else break
if (!nextMessage.isVoiceMessage) break
downloadFileToCache(nextMessage, false) {
val newFilename = nextMessage.selectedIndividualHashMap!!["name"]
val newFile = File(context.cacheDir, newFilename!!)
chatViewModel.queueInMediaPlayer(newFile.canonicalPath, nextMessage)
}
pos--
} while (true && pos >= 0)
}
private fun initMessageHolders(): MessageHolders {
val messageHolders = MessageHolders()
val profileBottomSheet = ProfileBottomSheet(ncApi, conversationUser!!, viewThemeUtils)
val payload = MessagePayload(
roomToken,
ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!),
profileBottomSheet
)
messageHolders.setIncomingTextConfig(
IncomingTextMessageViewHolder::class.java,
R.layout.item_custom_incoming_text_message,
payload
)
messageHolders.setOutcomingTextConfig(
OutcomingTextMessageViewHolder::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,
SystemMessageViewHolder::class.java,
R.layout.item_system_message,
SystemMessageViewHolder::class.java,
R.layout.item_system_message,
this
)
messageHolders.registerContentType(
CONTENT_TYPE_UNREAD_NOTICE_MESSAGE,
UnreadNoticeMessageViewHolder::class.java,
R.layout.item_date_header,
UnreadNoticeMessageViewHolder::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
)
messageHolders.registerContentType(
CONTENT_TYPE_LINK_PREVIEW,
IncomingLinkPreviewMessageViewHolder::class.java,
payload,
R.layout.item_custom_incoming_link_preview_message,
OutcomingLinkPreviewMessageViewHolder::class.java,
payload,
R.layout.item_custom_outcoming_link_preview_message,
this
)
messageHolders.registerContentType(
CONTENT_TYPE_DECK_CARD,
IncomingDeckCardViewHolder::class.java,
payload,
R.layout.item_custom_incoming_deck_card_message,
OutcomingDeckCardViewHolder::class.java,
payload,
R.layout.item_custom_outcoming_deck_card_message,
this
)
return messageHolders
}
@Suppress("MagicNumber", "LongMethod")
private fun updateTypingIndicator() {
fun ellipsize(text: String): String = DisplayUtils.ellipsize(text, TYPING_INDICATOR_MAX_NAME_LENGTH)
val participantNames = ArrayList<String>()
for (typingParticipant in typingParticipants.values) {
participantNames.add(typingParticipant.name)
}
val typingString: SpannableStringBuilder
when (typingParticipants.size) {
0 -> typingString = SpannableStringBuilder().append(binding.typingIndicator.text)
// person1 is typing
1 -> typingString = SpannableStringBuilder()
.bold { append(ellipsize(participantNames[0])) }
.append(WHITESPACE + context.resources?.getString(R.string.typing_is_typing))
// person1 and person2 are typing
2 -> typingString = SpannableStringBuilder()
.bold { append(ellipsize(participantNames[0])) }
.append(WHITESPACE + context.resources?.getString(R.string.nc_common_and) + WHITESPACE)
.bold { append(ellipsize(participantNames[1])) }
.append(WHITESPACE + context.resources?.getString(R.string.typing_are_typing))
// person1, person2 and person3 are typing
3 -> typingString = SpannableStringBuilder()
.bold { append(ellipsize(participantNames[0])) }
.append(COMMA)
.bold { append(ellipsize(participantNames[1])) }
.append(WHITESPACE + context.resources?.getString(R.string.nc_common_and) + WHITESPACE)
.bold { append(ellipsize(participantNames[2])) }
.append(WHITESPACE + context.resources?.getString(R.string.typing_are_typing))
// person1, person2, person3 and 1 other is typing
4 -> typingString = SpannableStringBuilder()
.bold { append(participantNames[0]) }
.append(COMMA)
.bold { append(participantNames[1]) }
.append(COMMA)
.bold { append(participantNames[2]) }
.append(WHITESPACE + context.resources?.getString(R.string.typing_1_other))
// person1, person2, person3 and x others are typing
else -> {
val moreTypersAmount = typingParticipants.size - 3
val othersTyping = context.resources?.getString(R.string.typing_x_others)?.let {
String.format(it, moreTypersAmount)
}
typingString = SpannableStringBuilder()
.bold { append(participantNames[0]) }
.append(COMMA)
.bold { append(participantNames[1]) }
.append(COMMA)
.bold { append(participantNames[2]) }
.append(othersTyping)
}
}
runOnUiThread {
binding.typingIndicator.text = typingString
val typingIndicatorPositionY = if (participantNames.size > 0) {
TYPING_INDICATOR_POSITION_VISIBLE
} else {
TYPING_INDICATOR_POSITION_HIDDEN
}
binding.typingIndicatorWrapper.animate()
.translationY(DisplayUtils.convertDpToPixel(typingIndicatorPositionY, context))
.setInterpolator(AccelerateDecelerateInterpolator())
.duration = TYPING_INDICATOR_ANIMATION_DURATION
}
}
private fun isTypingStatusEnabled(): Boolean =
webSocketInstance != null &&
!CapabilitiesUtil.isTypingStatusPrivate(conversationUser!!)
private fun setupSwipeToReply() {
if (this::participantPermissions.isInitialized &&
participantPermissions.hasChatPermission() &&
!isReadOnlyConversation()
) {
val messageSwipeCallback = MessageSwipeCallback(
this,
object : MessageSwipeActions {
override fun showReplyUI(position: Int) {
val chatMessage = adapter?.items?.getOrNull(position)?.item as ChatMessage?
if (chatMessage != null) {
messageInputViewModel.reply(chatMessage)
}
}
}
)
val itemTouchHelper = ItemTouchHelper(messageSwipeCallback)
itemTouchHelper.attachToRecyclerView(binding.messagesListView)
}
}
private fun loadAvatarForStatusBar() {
if (currentConversation == null) {
return
}
if (isOneToOneConversation()) {
var url = ApiUtils.getUrlForAvatar(
conversationUser!!.baseUrl!!,
currentConversation!!.name,
true
)
if (DisplayUtils.isDarkModeOn(supportActionBar?.themedContext!!)) {
url = "$url/dark"
}
val target = object : Target {
private fun setIcon(drawable: Drawable?) {
supportActionBar?.let {
val avatarSize = (it.height / TOOLBAR_AVATAR_RATIO).roundToInt()
val size = DisplayUtils.convertDpToPixel(STATUS_SIZE_IN_DP, context)
if (drawable != null && avatarSize > 0) {
val bitmap = drawable.toBitmap(avatarSize, avatarSize)
val status = StatusDrawable(
currentConversation!!.status,
null,
size,
0,
binding.chatToolbar.context
)
viewThemeUtils.talk.themeStatusDrawable(context, status)
binding.chatToolbar.findViewById<ImageView>(R.id.chat_toolbar_avatar)
.setImageDrawable(bitmap.toDrawable(resources))
binding.chatToolbar.findViewById<ImageView>(R.id.chat_toolbar_status)
.setImageDrawable(status)
binding.chatToolbar.findViewById<ImageView>(R.id.chat_toolbar_status).contentDescription =
currentConversation?.status
binding.chatToolbar.findViewById<FrameLayout>(R.id.chat_toolbar_avatar_container)
.visibility = View.VISIBLE
} else {
Log.d(TAG, "loadAvatarForStatusBar avatarSize <= 0")
}
}
}
override fun onStart(placeholder: Drawable?) {
this.setIcon(placeholder)
}
override fun onSuccess(result: Drawable) {
this.setIcon(result)
}
}
val credentials = ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token)
if (credentials != null) {
context.imageLoader.enqueue(
ImageRequest.Builder(context)
.data(url)
.addHeader("Authorization", credentials)
.transformations(CircleCropTransformation())
.crossfade(true)
.target(target)
.memoryCachePolicy(CachePolicy.DISABLED)
.diskCachePolicy(CachePolicy.DISABLED)
.build()
)
}
} else {
binding.chatToolbar.findViewById<FrameLayout>(R.id.chat_toolbar_avatar_container).visibility = View.GONE
}
}
fun isOneToOneConversation() =
currentConversation != null &&
currentConversation?.type != null &&
currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
private fun isGroupConversation() =
currentConversation != null &&
currentConversation?.type != null &&
currentConversation?.type == ConversationEnums.ConversationType.ROOM_GROUP_CALL
private fun isPublicConversation() =
currentConversation != null &&
currentConversation?.type != null &&
currentConversation?.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL
private fun updateRoomTimerHandler(delay: Long = -1) {
val delayForRecursiveCall = if (shouldShowLobby()) {
GET_ROOM_INFO_DELAY_LOBBY
} else {
GET_ROOM_INFO_DELAY_NORMAL
}
if (getRoomInfoTimerHandler == null) {
getRoomInfoTimerHandler = Handler()
}
getRoomInfoTimerHandler?.postDelayed(
{
chatViewModel.getRoom(roomToken)
},
if (delay > 0) delay else delayForRecursiveCall
)
}
private fun switchToRoom(token: String, startCallAfterRoomSwitch: Boolean, isVoiceOnlyCall: Boolean) {
if (conversationUser != null) {
runOnUiThread {
val toastInfo = if (currentConversation?.objectType == ConversationEnums.ObjectType.ROOM) {
context.resources.getString(R.string.switch_to_main_room)
} else {
context.resources.getString(R.string.switch_to_breakout_room)
}
// do not replace with snackbar, as it would disappear with the activity switch
Toast.makeText(
context,
toastInfo,
Toast.LENGTH_LONG
).show()
}
val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, token)
if (startCallAfterRoomSwitch) {
bundle.putBoolean(KEY_START_CALL_AFTER_ROOM_SWITCH, true)
bundle.putBoolean(KEY_CALL_VOICE_ONLY, isVoiceOnlyCall)
}
leaveRoom {
val chatIntent = Intent(context, ChatActivity::class.java)
chatIntent.putExtras(bundle)
chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(chatIntent)
}
}
}
private fun showCallButtonMenu(isVoiceOnlyCall: Boolean) {
val anchor: View? = if (isVoiceOnlyCall) {
findViewById(R.id.conversation_voice_call)
} else {
findViewById(R.id.conversation_video_call)
}
if (anchor != null) {
val popupMenu = PopupMenu(
ContextThemeWrapper(this, 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()
}
}
override fun updateMediaPlayerProgressBySlider(message: ChatMessage, progress: Int) {
chatViewModel.seekToMediaPlayer(progress)
}
override fun registerMessageToObservePlaybackSpeedPreferences(
userId: String,
listener: (speed: PlaybackSpeed) -> Unit
) {
CoroutineScope(Dispatchers.Default).launch {
chatViewModel.voiceMessagePlayBackUIFlow.onEach { speed ->
withContext(Dispatchers.Main) {
listener(speed)
}
}.collect()
}
}
@SuppressLint("NotifyDataSetChanged")
override fun collapseSystemMessages() {
adapter?.items?.forEach {
if (it.item is ChatMessage) {
val chatMessage = it.item as ChatMessage
if (isChildOfExpandableSystemMessage(chatMessage)) {
chatMessage.hiddenByCollapse = true
}
chatMessage.isExpanded = false
}
}
adapter?.notifyDataSetChanged()
}
private fun isChildOfExpandableSystemMessage(chatMessage: ChatMessage): Boolean =
isSystemMessage(chatMessage) &&
!chatMessage.expandableParent &&
chatMessage.lastItemOfExpandableGroup != 0
@SuppressLint("NotifyDataSetChanged")
override fun expandSystemMessage(chatMessageToExpand: ChatMessage) {
adapter?.items?.forEach {
if (it.item is ChatMessage) {
val belongsToGroupToExpand =
(it.item as ChatMessage).lastItemOfExpandableGroup == chatMessageToExpand.lastItemOfExpandableGroup
if (belongsToGroupToExpand) {
(it.item as ChatMessage).hiddenByCollapse = false
}
}
}
chatMessageToExpand.isExpanded = true
adapter?.notifyDataSetChanged()
}
@SuppressLint("LongLogTag")
private fun downloadFileToCache(
message: ChatMessage,
openWhenDownloaded: Boolean,
funToCallWhenDownloadSuccessful: (() -> Unit)
) {
message.isDownloadingVoiceMessage = true
message.openWhenDownloaded = openWhenDownloaded
adapter?.update(message)
val baseUrl = message.activeUser!!.baseUrl
val userId = message.activeUser!!.userId
val attachmentFolder = CapabilitiesUtil.getAttachmentFolder(
message.activeUser!!.capabilities!!
.spreedCapability!!
)
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) {
funToCallWhenDownloadSuccessful()
}
}
}
fun isRecordAudioPermissionGranted(): Boolean =
PermissionChecker.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
) == PERMISSION_GRANTED
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 requestReadFilesPermissions() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
requestPermissions(
arrayOf(
Manifest.permission.READ_MEDIA_IMAGES,
Manifest.permission.READ_MEDIA_VIDEO,
Manifest.permission.READ_MEDIA_AUDIO
),
REQUEST_SHARE_FILE_PERMISSION
)
} else {
requestPermissions(
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE
),
REQUEST_SHARE_FILE_PERMISSION
)
}
}
private fun checkShowCallButtons() {
if (isReadOnlyConversation() ||
shouldShowLobby() ||
ConversationUtils.isNoteToSelfConversation(currentConversation)
) {
disableCallButtons()
} else {
enableCallButtons()
}
}
private fun checkShowMessageInputView() {
if (isReadOnlyConversation() ||
shouldShowLobby() ||
!participantPermissions.hasChatPermission()
) {
binding.fragmentContainerActivityChat.visibility = View.GONE
} else {
binding.fragmentContainerActivityChat.visibility = View.VISIBLE
}
}
private fun shouldShowLobby(): Boolean {
if (currentConversation != null) {
return CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.WEBINARY_LOBBY) &&
currentConversation?.lobbyState == ConversationEnums.LobbyState.LOBBY_STATE_MODERATORS_ONLY &&
!ConversationUtils.canModerate(currentConversation!!, spreedCapabilities) &&
!participantPermissions.canIgnoreLobby()
}
return false
}
private fun disableCallButtons() {
if (CapabilitiesUtil.isAbleToCall(spreedCapabilities)) {
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 (CapabilitiesUtil.isAbleToCall(spreedCapabilities)) {
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 isEventConversation() {
if (currentConversation?.objectType == ConversationEnums.ObjectType.EVENT) {
if (eventConversationMenuItem != null) {
eventConversationMenuItem?.icon?.alpha = FULLY_OPAQUE_INT
eventConversationMenuItem?.isEnabled = true
}
} else {
eventConversationMenuItem?.isEnabled = false
}
}
private fun isReadOnlyConversation(): Boolean =
currentConversation?.conversationReadOnlyState != null &&
currentConversation?.conversationReadOnlyState ==
ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_ONLY
private fun checkLobbyState() {
if (currentConversation != null &&
ConversationUtils.isLobbyViewApplicable(currentConversation!!, spreedCapabilities) &&
shouldShowLobby()
) {
showLobbyView()
} else {
binding.lobby.lobbyView.visibility = View.GONE
binding.messagesListView.visibility = View.VISIBLE
checkShowMessageInputView()
}
}
private fun showLobbyView() {
binding.lobby.lobbyView.visibility = View.VISIBLE
binding.messagesListView.visibility = View.GONE
binding.fragmentContainerActivityChat.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 timestampMS = (currentConversation?.lobbyTimer ?: 0) * DateConstants.SECOND_DIVIDER
val stringWithStartDate = String.format(
resources!!.getString(R.string.nc_lobby_start_date),
dateUtils.getLocalDateTimeStringFromTimestamp(timestampMS)
)
val relativeTime = dateUtils.relativeStartTimeForLobby(timestampMS, resources!!)
sb.append("$stringWithStartDate - $relativeTime")
.append("\n\n")
}
sb.append(currentConversation!!.description)
binding.lobby.lobbyTextView.text = sb.toString()
}
private fun onRemoteFileBrowsingResult(intent: Intent?) {
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)
}
}
}
private fun onChooseFileResult(intent: Intent?) {
try {
checkNotNull(intent)
val fileUris = mutableListOf<Uri>()
intent.clipData?.let {
for (index in 0 until it.itemCount) {
fileUris.add(it.getItemAt(index).uri)
}
} ?: run {
checkNotNull(intent.data)
intent.data.let {
fileUris.add(intent.data!!)
}
}
onChooseFileResult(fileUris)
} catch (e: IllegalStateException) {
context.resources?.getString(R.string.nc_upload_failed)?.let {
Snackbar.make(
binding.root,
it,
Snackbar.LENGTH_LONG
).show()
}
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
}
}
private fun onChooseFileResult(filesToUpload: List<Uri>) {
try {
require(filesToUpload.isNotEmpty())
val filenamesWithLineBreaks = StringBuilder("\n")
for (file in filesToUpload) {
val filename = FileUtils.getFileName(file, context)
filenamesWithLineBreaks.append(filename).append("\n")
}
val newFragment = FileAttachmentPreviewFragment.newInstance(
filenamesWithLineBreaks.toString(),
filesToUpload.map { it.toString() }.toMutableList()
)
newFragment.setListener { files, caption ->
uploadFiles(files, caption)
}
newFragment.show(supportFragmentManager, FileAttachmentPreviewFragment.TAG)
} catch (e: IllegalStateException) {
context.resources?.getString(R.string.nc_upload_failed)?.let {
Snackbar.make(
binding.root,
it,
Snackbar.LENGTH_LONG
).show()
}
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
} catch (e: IllegalArgumentException) {
context.resources?.getString(R.string.nc_upload_failed)?.let {
Snackbar.make(
binding.root,
it,
Snackbar.LENGTH_LONG
).show()
}
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
}
}
private fun onSelectContactResult(intent: Intent?) {
val contactUri = intent?.data ?: return
val cursor: Cursor? = 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(
this,
BuildConfig.APPLICATION_ID,
File(file.absolutePath)
)
uploadFile(shareUri.toString(), false)
}
cursor?.close()
}
@Throws(IllegalStateException::class)
private fun onPickCameraResult(intent: Intent?) {
try {
filesToUpload.clear()
if (intent != null && intent.data != null) {
run {
intent.data.let {
filesToUpload.add(intent.data.toString())
}
}
require(filesToUpload.isNotEmpty())
} else if (videoURI != null) {
filesToUpload.add(videoURI.toString())
videoURI = null
} else {
error("Failed to get data from intent and uri")
}
if (permissionUtil.isFilesPermissionGranted()) {
val filenamesWithLineBreaks = StringBuilder("\n")
for (file in filesToUpload) {
val filename = FileUtils.getFileName(file.toUri(), context)
filenamesWithLineBreaks.append(filename).append("\n")
}
val newFragment = FileAttachmentPreviewFragment.newInstance(
filenamesWithLineBreaks.toString(),
filesToUpload
)
newFragment.setListener { files, caption -> uploadFiles(files, caption) }
newFragment.show(supportFragmentManager, FileAttachmentPreviewFragment.TAG)
} else {
UploadAndShareFilesWorker.requestStoragePermission(this)
}
} catch (e: IllegalStateException) {
Snackbar.make(
binding.root,
R.string.nc_upload_failed,
Snackbar.LENGTH_LONG
)
.show()
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
} catch (e: IllegalArgumentException) {
context.resources?.getString(R.string.nc_upload_failed)?.let {
Snackbar.make(
binding.root,
it,
Snackbar.LENGTH_LONG
)
.show()
}
Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e)
}
}
private fun onMessageSearchResult(intent: Intent?) {
val messageId = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_MESSAGE_ID)
messageId?.let { id ->
scrollToAndCenterMessageWithId(id)
}
}
private fun executeIfResultOk(result: ActivityResult, onResult: (intent: Intent?) -> Unit) {
if (result.resultCode == Activity.RESULT_OK) {
onResult(result.data)
} else {
Log.e(TAG, "resultCode for received intent was != ok")
}
}
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.scrollToPosition(position)
} else {
startContextChatWindowForMessage(messageId)
}
}
private fun scrollToAndCenterMessageWithId(messageId: String) {
adapter?.let {
val position = it.getMessagePositionByIdInReverse(messageId)
if (position != -1) {
layoutManager?.scrollToPositionWithOffset(
position,
binding.messagesListView.height / 2
)
} else {
startContextChatWindowForMessage(messageId)
}
} ?: run {
startContextChatWindowForMessage(messageId)
}
}
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 = contentResolver!!.openAssetFileDescriptor(uri, "r")!!
fd.use {
val fis = fd.createInputStream()
file.createNewFile()
fis.use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
}
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == UploadAndShareFilesWorker.REQUEST_PERMISSION) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "upload starting after permissions were granted")
if (filesToUpload.isNotEmpty()) {
uploadFiles(filesToUpload)
}
} else {
Snackbar
.make(binding.root, context.getString(R.string.read_storage_no_permission), Snackbar.LENGTH_LONG)
.show()
}
} else if (requestCode == REQUEST_SHARE_FILE_PERMISSION) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
showLocalFilePicker()
} else {
Snackbar.make(
binding.root,
context.getString(R.string.nc_file_storage_permission),
Snackbar.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 {
Snackbar.make(
binding.root,
context.getString(R.string.nc_voice_message_missing_audio_permission),
Snackbar.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)
startSelectContactForResult.launch(intent)
} else {
Snackbar.make(
binding.root,
context.getString(R.string.nc_share_contact_permission),
Snackbar.LENGTH_LONG
).show()
}
} else if (requestCode == REQUEST_CAMERA_PERMISSION) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Snackbar
.make(binding.root, context.getString(R.string.camera_permission_granted), Snackbar.LENGTH_LONG)
.show()
} else {
Snackbar
.make(binding.root, context.getString(R.string.take_photo_permission), Snackbar.LENGTH_LONG)
.show()
}
}
}
private fun uploadFiles(files: MutableList<String>, caption: String = "") {
for (i in 0 until files.size) {
if (i == files.size - 1) {
uploadFile(files[i], false, caption)
} else {
uploadFile(files[i], false)
}
}
}
private fun uploadFile(fileUri: String, isVoiceMessage: Boolean, caption: String = "", token: String = "") {
var metaData = ""
var room = ""
if (!participantPermissions.hasChatPermission()) {
Log.w(TAG, "uploading file(s) is forbidden because of missing attendee permissions")
return
}
if (isVoiceMessage) {
metaData = VOICE_MESSAGE_META_DATA
}
if (caption != "") {
metaData = "{\"caption\":\"$caption\"}"
}
if (token == "") room = roomToken else room = token
chatViewModel.uploadFile(fileUri, room, currentConversation?.displayName!!, metaData)
}
fun showGalleryPicker() {
pickMultipleMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo))
}
private fun showLocalFilePicker() {
val action = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "*/*"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
startChooseFileIntentForResult.launch(
Intent.createChooser(
action,
context.resources?.getString(
R.string.nc_upload_choose_local_files
)
)
)
}
fun sendSelectLocalFileIntent() {
if (!permissionUtil.isFilesPermissionGranted()) {
requestReadFilesPermissions()
} else {
showLocalFilePicker()
}
}
fun sendChooseContactIntent() {
requestReadContacts()
}
fun showBrowserScreen() {
val sharingFileBrowserIntent = Intent(this, RemoteFileBrowserActivity::class.java)
startRemoteFileBrowsingForResult.launch(sharingFileBrowserIntent)
}
fun showShareLocationScreen() {
Log.d(TAG, "showShareLocationScreen")
val intent = Intent(this, LocationPickerActivity::class.java)
intent.putExtra(KEY_ROOM_TOKEN, roomToken)
intent.putExtra(BundleKeys.KEY_CHAT_API_VERSION, chatApiVersion)
startActivity(intent)
}
private fun showConversationInfoScreen() {
val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, roomToken)
bundle.putBoolean(BundleKeys.KEY_ROOM_ONE_TO_ONE, isOneToOneConversation())
val intent = Intent(this, ConversationInfoActivity::class.java)
intent.putExtras(bundle)
startActivity(intent)
}
private fun validSessionId(): Boolean =
currentConversation != null &&
sessionIdAfterRoomJoined?.isNotEmpty() == true &&
sessionIdAfterRoomJoined != "0"
@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 onPause() {
super.onPause()
logConversationInfos("onPause")
eventBus.unregister(this)
webSocketInstance?.getSignalingMessageReceiver()?.removeListener(localParticipantMessageListener)
webSocketInstance?.getSignalingMessageReceiver()?.removeListener(conversationMessageListener)
findViewById<View>(R.id.toolbar)?.setOnClickListener(null)
checkingLobbyStatus = false
if (getRoomInfoTimerHandler != null) {
getRoomInfoTimerHandler?.removeCallbacksAndMessages(null)
}
if (conversationUser != null && isActivityNotChangingConfigurations() && isNotInCall()) {
ApplicationWideCurrentRoomHolder.getInstance().clear()
if (validSessionId()) {
leaveRoom(null)
} else {
Log.d(TAG, "not leaving room (validSessionId is false)")
}
} else {
Log.d(TAG, "not leaving room...")
}
if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) {
mentionAutocomplete?.dismissPopup()
}
}
private fun isActivityNotChangingConfigurations(): Boolean = !isChangingConfigurations
private fun isNotInCall(): Boolean =
!ApplicationWideCurrentRoomHolder.getInstance().isInCall &&
!ApplicationWideCurrentRoomHolder.getInstance().isDialing
private fun setActionBarTitle() {
val title = binding.chatToolbar.findViewById<TextView>(R.id.chat_toolbar_title)
viewThemeUtils.platform.colorTextView(title, ColorRole.ON_SURFACE)
title.text =
if (currentConversation?.displayName != null) {
try {
EmojiCompat.get().process(currentConversation?.displayName as CharSequence).toString()
} catch (e: java.lang.IllegalStateException) {
Log.e(TAG, "setActionBarTitle failed $e")
currentConversation?.displayName
}
} else {
""
}
if (currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
var statusMessage = ""
if (currentConversation?.statusIcon != null) {
statusMessage += currentConversation?.statusIcon
}
if (currentConversation?.statusMessage != null) {
statusMessage += currentConversation?.statusMessage
}
statusMessageViewContents(statusMessage)
} else {
if (currentConversation?.type == ConversationEnums.ConversationType.ROOM_GROUP_CALL ||
currentConversation?.type == ConversationEnums.ConversationType.ROOM_PUBLIC_CALL
) {
var descriptionMessage = ""
descriptionMessage += currentConversation?.description
statusMessageViewContents(descriptionMessage)
}
}
}
private fun statusMessageViewContents(statusMessageContent: String) {
val statusMessageView = binding.chatToolbar.findViewById<TextView>(R.id.chat_toolbar_status_message)
if (statusMessageContent.isNotEmpty()) {
viewThemeUtils.platform.colorTextView(statusMessageView, ColorRole.ON_SURFACE)
statusMessageView.text = statusMessageContent
statusMessageView.visibility = View.VISIBLE
} else {
statusMessageView.visibility = View.GONE
}
}
public override fun onDestroy() {
super.onDestroy()
logConversationInfos("onDestroy")
findViewById<View>(R.id.toolbar)?.setOnClickListener(null)
if (actionBar != null) {
actionBar?.setIcon(null)
}
adapter = null
disposables.dispose()
}
private fun joinRoomWithPassword() {
// if ApplicationWideCurrentRoomHolder contains a session (because a call is active), then keep the sessionId
if (ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken ==
currentConversation!!.token
) {
sessionIdAfterRoomJoined = ApplicationWideCurrentRoomHolder.getInstance().session
ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = roomToken
ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser
}
if (!validSessionId()) {
Log.d(TAG, "sessionID was not valid -> joinRoom")
val startNanoTime = System.nanoTime()
Log.d(TAG, "joinRoomWithPassword - joinRoom - calling: $startNanoTime")
chatViewModel.joinRoom(conversationUser!!, roomToken, roomPassword)
} else {
Log.d(TAG, "sessionID was valid -> skip joinRoom")
setupWebsocket()
}
}
fun leaveRoom(funToCallWhenLeaveSuccessful: (() -> Unit)?) {
logConversationInfos("leaveRoom")
var apiVersion = 1
// FIXME Fix API checking with guests?
if (conversationUser != null) {
apiVersion = ApiUtils.getConversationApiVersion(conversationUser!!, intArrayOf(ApiUtils.API_V4, 1))
}
val startNanoTime = System.nanoTime()
Log.d(TAG, "leaveRoom - leaveRoom - calling: $startNanoTime")
chatViewModel.leaveRoom(
credentials!!,
ApiUtils.getUrlForParticipantsActive(
apiVersion,
conversationUser?.baseUrl!!,
roomToken
),
funToCallWhenLeaveSuccessful
)
}
private fun setupWebsocket() {
if (currentConversation == null || conversationUser == null) {
return
}
if (currentConversation!!.remoteServer?.isNotEmpty() == true) {
val apiVersion = ApiUtils.getSignalingApiVersion(conversationUser!!, intArrayOf(ApiUtils.API_V3, 2, 1))
ncApi.getSignalingSettings(
credentials,
ApiUtils.getUrlForSignalingSettings(apiVersion, conversationUser!!.baseUrl, roomToken)
)
.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<SignalingSettingsOverall> {
override fun onSubscribe(d: Disposable) {
disposables.add(d)
}
override fun onNext(signalingSettingsOverall: SignalingSettingsOverall) {
if (signalingSettingsOverall.ocs!!.settings!!.externalSignalingServer == null ||
signalingSettingsOverall.ocs!!.settings!!.externalSignalingServer?.isEmpty() == true
) {
return
}
externalSignalingServer = ExternalSignalingServer()
externalSignalingServer!!.externalSignalingServer = signalingSettingsOverall.ocs!!.settings!!
.externalSignalingServer
externalSignalingServer!!.externalSignalingTicket = signalingSettingsOverall.ocs!!.settings!!
.externalSignalingTicket
externalSignalingServer!!.federation = signalingSettingsOverall.ocs!!.settings!!.federation
webSocketInstance = WebSocketConnectionHelper.getExternalSignalingInstanceForServer(
externalSignalingServer!!.externalSignalingServer,
conversationUser,
externalSignalingServer!!.externalSignalingTicket,
TextUtils.isEmpty(credentials)
)
if (webSocketInstance != null) {
webSocketInstance?.joinRoomWithRoomTokenAndSession(
roomToken,
sessionIdAfterRoomJoined,
externalSignalingServer?.federation
)
}
signalingMessageSender = webSocketInstance?.signalingMessageSender
webSocketInstance?.getSignalingMessageReceiver()?.addListener(localParticipantMessageListener)
webSocketInstance?.getSignalingMessageReceiver()?.addListener(conversationMessageListener)
}
override fun onError(e: Throwable) {
Log.e(TAG, e.message, e)
}
override fun onComplete() {
// unused atm
}
})
} else {
webSocketInstance = WebSocketConnectionHelper.getWebSocketInstanceForUser(conversationUser!!)
if (webSocketInstance != null) {
webSocketInstance?.joinRoomWithRoomTokenAndSession(
roomToken,
sessionIdAfterRoomJoined,
null
)
signalingMessageSender = webSocketInstance?.signalingMessageSender
webSocketInstance?.getSignalingMessageReceiver()?.addListener(localParticipantMessageListener)
webSocketInstance?.getSignalingMessageReceiver()?.addListener(conversationMessageListener)
} else {
Log.d(TAG, "webSocketInstance not set up. This is only expected when not using the HPB")
}
}
}
private fun processCallStartedMessages() {
try {
val mostRecentCallSystemMessage = adapter?.items?.first {
it.item is ChatMessage &&
(it.item as ChatMessage).systemMessageType in
listOf(
ChatMessage.SystemMessageType.CALL_STARTED,
ChatMessage.SystemMessageType.CALL_JOINED,
ChatMessage.SystemMessageType.CALL_LEFT,
ChatMessage.SystemMessageType.CALL_ENDED,
ChatMessage.SystemMessageType.CALL_TRIED,
ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE,
ChatMessage.SystemMessageType.CALL_MISSED
)
}?.item
if (mostRecentCallSystemMessage != null) {
processMostRecentMessage(
mostRecentCallSystemMessage as ChatMessage
)
}
} catch (e: NoSuchElementException) {
Log.d(TAG, "No System messages found $e")
}
}
private fun processExpiredMessages() {
@SuppressLint("NotifyDataSetChanged")
fun deleteExpiredMessages() {
val messagesToDelete: ArrayList<ChatMessage> = ArrayList()
val systemTime = System.currentTimeMillis() / ONE_SECOND_IN_MILLIS
if (adapter?.items != null) {
for (itemWrapper in adapter?.items!!) {
if (itemWrapper.item is ChatMessage) {
val chatMessage = itemWrapper.item as ChatMessage
if (chatMessage.expirationTimestamp != 0 && chatMessage.expirationTimestamp < systemTime) {
messagesToDelete.add(chatMessage)
}
}
}
adapter!!.delete(messagesToDelete)
adapter!!.notifyDataSetChanged()
}
}
if (this::spreedCapabilities.isInitialized) {
if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MESSAGE_EXPIRATION)) {
deleteExpiredMessages()
}
} else {
Log.w(TAG, "spreedCapabilities are not initialized in processExpiredMessages()")
}
}
private fun updateReadStatusOfAllMessages(xChatLastCommonRead: Int?) {
if (adapter != null) {
for (message in adapter!!.items) {
xChatLastCommonRead?.let {
updateReadStatusOfMessage(message, it)
}
}
adapter!!.notifyDataSetChanged()
}
}
private fun updateReadStatusOfMessage(
message: MessagesListAdapter<IMessage>.Wrapper<Any>,
xChatLastCommonRead: Int
) {
if (message.item is ChatMessage) {
val chatMessage = message.item as ChatMessage
if (chatMessage.jsonMessageId <= xChatLastCommonRead) {
chatMessage.readStatus = ReadStatus.READ
} else {
chatMessage.readStatus = ReadStatus.SENT
}
}
}
private fun processMessagesFromTheFuture(chatMessageList: List<ChatMessage>, setUnreadMessagesMarker: Boolean) {
binding.scrollDownButton.visibility = View.GONE
val scrollToBottom: Boolean
if (setUnreadMessagesMarker) {
scrollToBottom = false
setUnreadMessageMarker(chatMessageList)
} else {
if (isScrolledToBottom()) {
scrollToBottom = true
} else {
scrollToBottom = false
binding.unreadMessagesPopup.visibility = View.VISIBLE
// here we have the problem that the chat jumps for every update
}
}
for (chatMessage in chatMessageList) {
chatMessage.activeUser = conversationUser
adapter?.let {
val previousChatMessage = it.items?.getOrNull(1)?.item
if (previousChatMessage != null && previousChatMessage is ChatMessage) {
chatMessage.isGrouped = groupMessages(chatMessage, previousChatMessage)
}
chatMessage.isOneToOneConversation =
(currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL)
chatMessage.isFormerOneToOneConversation =
(currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE)
Log.d(TAG, "chatMessage to add:" + chatMessage.message)
it.addToStart(chatMessage, scrollToBottom)
}
}
// workaround to jump back to unread messages marker
if (setUnreadMessagesMarker) {
scrollToFirstUnreadMessage()
}
}
private fun isScrolledToBottom(): Boolean {
val position = layoutManager?.findFirstVisibleItemPosition()
if (position == -1) {
Log.w(
TAG,
"FirstVisibleItemPosition was -1 but true is returned for isScrolledToBottom(). This can " +
"happen when the UI is not yet ready"
)
return true
}
return layoutManager?.findFirstVisibleItemPosition() == 0
}
private fun setUnreadMessageMarker(chatMessageList: List<ChatMessage>) {
if (chatMessageList.isNotEmpty()) {
val unreadChatMessage = ChatMessage()
unreadChatMessage.jsonMessageId = UNREAD_MESSAGES_MARKER_ID
unreadChatMessage.actorId = "-1"
unreadChatMessage.timestamp = chatMessageList[0].timestamp
unreadChatMessage.message = context.getString(R.string.nc_new_messages)
adapter?.addToStart(unreadChatMessage, false)
}
}
private fun processMessagesNotFromTheFuture(chatMessageList: List<ChatMessage>) {
for (i in chatMessageList.indices) {
if (chatMessageList.size > i + 1) {
chatMessageList[i].isGrouped = groupMessages(chatMessageList[i], chatMessageList[i + 1])
}
val chatMessage = chatMessageList[i]
chatMessage.isOneToOneConversation =
currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
chatMessage.isFormerOneToOneConversation =
(currentConversation?.type == ConversationEnums.ConversationType.FORMER_ONE_TO_ONE)
chatMessage.activeUser = conversationUser
chatMessage.token = roomToken
}
if (adapter != null) {
adapter?.addToEnd(chatMessageList, false)
}
scrollToRequestedMessageIfNeeded()
}
private fun scrollToFirstUnreadMessage() {
adapter?.let {
scrollToAndCenterMessageWithId(UNREAD_MESSAGES_MARKER_ID.toString())
}
}
private fun groupMessages(message1: ChatMessage, message2: ChatMessage): Boolean {
val message1IsSystem = message1.systemMessage.isNotEmpty()
val message2IsSystem = message2.systemMessage.isNotEmpty()
if (message1IsSystem != message2IsSystem) {
return false
}
if (message1.actorType == "bots" && message1.actorId != "changelog") {
return false
}
if (!message1IsSystem &&
(
(message1.actorType != message2.actorType) ||
(message2.actorId != message1.actorId)
)
) {
return false
}
val timeDifference = dateUtils.getTimeDifferenceInSeconds(
message2.timestamp,
message1.timestamp
)
val isLessThan5Min = timeDifference > FIVE_MINUTES_IN_SECONDS
return isSameDayMessages(message2, message1) &&
(message2.actorId == message1.actorId) &&
(!isLessThan5Min) &&
(message2.lastEditTimestamp == 0L || message1.lastEditTimestamp == 0L)
}
private fun determinePreviousMessageIds(chatMessageList: List<ChatMessage>) {
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 {
adapter?.let {
if (!it.isEmpty) {
if (it.items[0].item is ChatMessage) {
chatMessage.previousMessageId = (it.items[0].item as ChatMessage).jsonMessageId
} else if (it.items.size > 1 && it.items[1].item is ChatMessage) {
chatMessage.previousMessageId = (it.items[1].item as ChatMessage).jsonMessageId
}
}
}
}
previousMessageId = chatMessage.jsonMessageId
}
}
private fun getItemFromAdapter(messageId: String): Pair<ChatMessage, Int>? {
if (adapter != null) {
val messagePosition = adapter!!.items!!.indexOfFirst {
it.item is ChatMessage && (it.item as ChatMessage).id == messageId
}
if (messagePosition >= 0) {
val currentItem = adapter?.items?.get(messagePosition)?.item
if (currentItem is ChatMessage && currentItem.id == messageId) {
return Pair(currentItem, messagePosition)
} else {
Log.d(TAG, "currentItem retrieved was not chatmessage or its id was not correct")
}
} else {
Log.d(TAG, "messagePosition is -1, adapter # of items: " + adapter!!.itemCount)
}
} else {
Log.d(TAG, "TalkMessagesListAdapter is null")
}
return null
}
private fun scrollToRequestedMessageIfNeeded() {
intent.getStringExtra(BundleKeys.KEY_MESSAGE_ID)?.let {
scrollToMessageWithId(it)
}
}
private fun isSameDayNonSystemMessages(messageLeft: ChatMessage, messageRight: ChatMessage): Boolean =
TextUtils.isEmpty(messageLeft.systemMessage) &&
TextUtils.isEmpty(messageRight.systemMessage) &&
DateFormatter.isSameDay(messageLeft.createdAt, messageRight.createdAt)
private fun isSameDayMessages(message1: ChatMessage, message2: ChatMessage): Boolean =
DateFormatter.isSameDay(message1.createdAt, message2.createdAt)
override fun onLoadMore(page: Int, totalItemsCount: Int) {
val messageId = (
adapter?.items
?.lastOrNull { it.item is ChatMessage }
?.item as? ChatMessage
)?.jsonMessageId
messageId?.let {
val urlForChatting = ApiUtils.getUrlForChat(chatApiVersion, conversationUser?.baseUrl, roomToken)
chatViewModel.loadMoreMessages(
beforeMessageId = it.toLong(),
withUrl = urlForChatting,
withCredentials = credentials!!,
withMessageLimit = MESSAGE_PULL_LIMIT,
roomToken = currentConversation!!.token
)
}
}
override fun format(date: Date): String =
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): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.menu_conversation, menu)
if (currentConversation?.objectType == ConversationEnums.ObjectType.EVENT) {
eventConversationMenuItem = menu.findItem(R.id.conversation_event)
} else {
menu.removeItem(R.id.conversation_event)
}
if (conversationUser?.userId == "?") {
menu.removeItem(R.id.conversation_info)
} else {
loadAvatarForStatusBar()
setActionBarTitle()
}
return true
}
override fun onPrepareOptionsMenu(menu: Menu): Boolean {
super.onPrepareOptionsMenu(menu)
if (this::spreedCapabilities.isInitialized) {
if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.READ_ONLY_ROOMS)) {
checkShowCallButtons()
}
val searchItem = menu.findItem(R.id.conversation_search)
searchItem.isVisible = CapabilitiesUtil.isUnifiedSearchAvailable(spreedCapabilities) &&
currentConversation!!.remoteServer.isNullOrEmpty()
if (CapabilitiesUtil.isAbleToCall(spreedCapabilities)) {
conversationVoiceCallMenuItem = menu.findItem(R.id.conversation_voice_call)
conversationVideoMenuItem = menu.findItem(R.id.conversation_video_call)
this.lifecycleScope.launch {
networkMonitor.isOnline.onEach { isOnline ->
conversationVoiceCallMenuItem?.isVisible = isOnline
searchItem?.isVisible = isOnline
conversationVideoMenuItem?.isVisible = isOnline
}.collect()
}
if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.SILENT_CALL)) {
Handler().post {
findViewById<View?>(R.id.conversation_voice_call)?.setOnLongClickListener {
showCallButtonMenu(true)
true
}
}
Handler().post {
findViewById<View?>(R.id.conversation_video_call)?.setOnLongClickListener {
showCallButtonMenu(false)
true
}
}
}
} else {
menu.removeItem(R.id.conversation_video_call)
menu.removeItem(R.id.conversation_voice_call)
}
}
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean =
when (item.itemId) {
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
}
R.id.conversation_event -> {
val anchorView = findViewById<View>(R.id.conversation_event)
showPopupWindow(anchorView)
true
}
else -> super.onOptionsItemSelected(item)
}
private fun showPopupWindow(anchorView: View) {
val popupView = layoutInflater.inflate(R.layout.item_event_schedule, null)
val titleTextView = popupView.findViewById<TextView>(R.id.event_scheduled)
val subtitleTextView = popupView.findViewById<TextView>(R.id.meetingTime)
val popupWindow = PopupWindow(
popupView,
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
true
)
popupWindow.isOutsideTouchable = true
popupWindow.isFocusable = true
popupWindow.showAsDropDown(anchorView, 0, -anchorView.height)
val meetingStatus = showEventSchedule()
subtitleTextView.text = meetingStatus
deleteEventConversation(meetingStatus, popupWindow, popupView)
archiveEventConversation(meetingStatus, popupWindow, popupView)
}
private fun deleteEventConversation(meetingStatus: String, popupWindow: PopupWindow, popupView: View) {
val deleteConversation = popupView.findViewById<TextView>(R.id.delete_conversation)
if (meetingStatus == context.resources.getString(R.string.nc_meeting_ended) &&
currentConversation?.canDeleteConversation == true
) {
deleteConversation.visibility = View.VISIBLE
deleteConversation.setOnClickListener {
deleteConversationDialog(it.context)
popupWindow.dismiss()
}
} else {
deleteConversation.visibility = View.GONE
}
}
private fun archiveEventConversation(meetingStatus: String, popupWindow: PopupWindow, popupView: View) {
val archiveConversation = popupView.findViewById<TextView>(R.id.archive_conversation)
val unarchiveConversation = popupView.findViewById<TextView>(R.id.unarchive_conversation)
if (meetingStatus == context.resources.getString(R.string.nc_meeting_ended) &&
(
Participant.ParticipantType.MODERATOR == currentConversation?.participantType ||
Participant.ParticipantType.OWNER == currentConversation?.participantType
)
) {
if (currentConversation?.hasArchived == false) {
unarchiveConversation.visibility = View.GONE
archiveConversation.visibility = View.VISIBLE
archiveConversation.setOnClickListener {
this.lifecycleScope.launch {
conversationInfoViewModel.archiveConversation(conversationUser!!, currentConversation?.token!!)
Snackbar.make(
binding.root,
String.format(
context.resources.getString(R.string.archived_conversation),
currentConversation?.displayName
),
Snackbar.LENGTH_LONG
).show()
}
popupWindow.dismiss()
}
} else {
unarchiveConversation.visibility = View.VISIBLE
archiveConversation.visibility = View.GONE
unarchiveConversation.setOnClickListener {
this.lifecycleScope.launch {
conversationInfoViewModel.unarchiveConversation(
conversationUser!!,
currentConversation?.token!!
)
Snackbar.make(
binding.root,
String.format(
context.resources.getString(R.string.unarchived_conversation),
currentConversation?.displayName
),
Snackbar.LENGTH_LONG
).show()
}
popupWindow.dismiss()
}
}
} else {
archiveConversation.visibility = View.GONE
unarchiveConversation.visibility = View.GONE
}
}
private fun deleteConversation(conversation: ConversationModel) {
val data = Data.Builder()
data.putLong(
KEY_INTERNAL_USER_ID,
conversationUser?.id!!
)
data.putString(KEY_ROOM_TOKEN, conversation.token)
val deleteConversationWorker =
OneTimeWorkRequest.Builder(DeleteConversationWorker::class.java).setInputData(data.build()).build()
WorkManager.getInstance().enqueue(deleteConversationWorker)
WorkManager.getInstance(context).getWorkInfoByIdLiveData(deleteConversationWorker.id)
.observeForever { workInfo: WorkInfo? ->
if (workInfo != null) {
when (workInfo.state) {
WorkInfo.State.SUCCEEDED -> {
val successMessage = String.format(
context.resources.getString(R.string.deleted_conversation),
conversation.displayName
)
Snackbar.make(binding.root, successMessage, Snackbar.LENGTH_LONG).show()
finish()
}
WorkInfo.State.FAILED -> {
val errorMessage = context.resources.getString(R.string.nc_common_error_sorry)
Snackbar.make(binding.root, errorMessage, Snackbar.LENGTH_LONG).show()
}
else -> {
}
}
}
}
}
private fun showEventSchedule(): String {
val meetingTimeStamp = currentConversation?.objectId ?: ""
val status = getMeetingSchedule(meetingTimeStamp)
return status
}
private fun getMeetingSchedule(meetingTimeStamp: String): String {
val timestamps = meetingTimeStamp.split("#")
if (timestamps.size != 2) return context.resources.getString(R.string.nc_invalid_time)
val startEpoch = timestamps[ZERO_INDEX].toLong()
val endEpoch = timestamps[ONE_INDEX].toLong()
val startDateTime = Instant.ofEpochSecond(startEpoch).atZone(ZoneId.systemDefault())
val endDateTime = Instant.ofEpochSecond(endEpoch).atZone(ZoneId.systemDefault())
val currentTime = ZonedDateTime.now(ZoneId.systemDefault())
return when {
currentTime.isBefore(startDateTime) -> {
val isToday = startDateTime.toLocalDate().isEqual(currentTime.toLocalDate())
val isTomorrow = startDateTime.toLocalDate().isEqual(currentTime.toLocalDate().plusDays(1))
when {
isToday -> String.format(
context.resources.getString(R.string.nc_today_meeting),
startDateTime.format(DateTimeFormatter.ofPattern("HH:mm"))
)
isTomorrow -> String.format(
context.resources.getString(R.string.nc_tomorrow_meeting),
startDateTime.format(DateTimeFormatter.ofPattern("HH:mm"))
)
else -> startDateTime.format(DateTimeFormatter.ofPattern("MMM d, yyyy, HH:mm"))
}
}
currentTime.isAfter(endDateTime) -> context.resources.getString(R.string.nc_meeting_ended)
else -> context.resources.getString(R.string.nc_ongoing_meeting)
}
}
private fun showSharedItems() {
val intent = Intent(this, SharedItemsActivity::class.java)
intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName)
intent.putExtra(KEY_ROOM_TOKEN, roomToken)
intent.putExtra(
SharedItemsActivity.KEY_USER_IS_OWNER_OR_MODERATOR,
ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!)
)
startActivity(intent)
}
private fun startMessageSearch() {
val intent = Intent(this, MessageSearchActivity::class.java)
intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName)
intent.putExtra(KEY_ROOM_TOKEN, roomToken)
startMessageSearchForResult.launch(intent)
}
private fun handleSystemMessages(chatMessageList: List<ChatMessage>): List<ChatMessage> {
val chatMessageMap = chatMessageList.map { it.id to it }.toMap().toMutableMap()
val chatMessageIterator = chatMessageMap.iterator()
while (chatMessageIterator.hasNext()) {
val currentMessage = chatMessageIterator.next()
if (isInfoMessageAboutDeletion(currentMessage) ||
isReactionsMessage(currentMessage) ||
isPollVotedMessage(currentMessage) ||
isEditMessage(currentMessage)
) {
chatMessageIterator.remove()
}
}
return chatMessageMap.values.toList()
}
private fun handleExpandableSystemMessages(chatMessageList: List<ChatMessage>): List<ChatMessage> {
val chatMessageMap = chatMessageList.map { it.id to it }.toMap().toMutableMap()
val chatMessageIterator = chatMessageMap.iterator()
while (chatMessageIterator.hasNext()) {
val currentMessage = chatMessageIterator.next()
val previousMessage = chatMessageMap[currentMessage.value.previousMessageId.toString()]
if (isSystemMessage(currentMessage.value) &&
previousMessage?.systemMessageType == currentMessage.value.systemMessageType
) {
previousMessage?.expandableParent = true
currentMessage.value.expandableParent = false
if (currentMessage.value.lastItemOfExpandableGroup == 0) {
currentMessage.value.lastItemOfExpandableGroup = currentMessage.value.jsonMessageId
}
previousMessage?.lastItemOfExpandableGroup = currentMessage.value.lastItemOfExpandableGroup
previousMessage?.expandableChildrenAmount = currentMessage.value.expandableChildrenAmount + 1
}
}
return chatMessageMap.values.toList()
}
private fun isInfoMessageAboutDeletion(currentMessage: MutableMap.MutableEntry<String, ChatMessage>): Boolean =
currentMessage.value.parentMessageId != null &&
currentMessage.value.systemMessageType == ChatMessage
.SystemMessageType.MESSAGE_DELETED
private fun isReactionsMessage(currentMessage: MutableMap.MutableEntry<String, ChatMessage>): Boolean =
currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION ||
currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION_DELETED ||
currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION_REVOKED
private fun isEditMessage(currentMessage: MutableMap.MutableEntry<String, ChatMessage>): Boolean =
currentMessage.value.parentMessageId != null &&
currentMessage.value.systemMessageType == ChatMessage
.SystemMessageType.MESSAGE_EDITED
private fun isPollVotedMessage(currentMessage: MutableMap.MutableEntry<String, ChatMessage>): Boolean =
currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.POLL_VOTED
private fun startACall(isVoiceOnlyCall: Boolean, callWithoutNotification: Boolean) {
currentConversation?.let {
if (conversationUser != null) {
val pp = ParticipantPermissions(spreedCapabilities, it)
if (!pp.canStartCall() && currentConversation?.hasCall == false) {
Snackbar.make(binding.root, R.string.startCallForbidden, Snackbar.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(BundleKeys.KEY_CONVERSATION_PASSWORD, roomPassword)
bundle.putString(BundleKeys.KEY_MODIFIED_BASE_URL, conversationUser?.baseUrl!!)
bundle.putString(KEY_CONVERSATION_NAME, it.displayName)
bundle.putInt(KEY_RECORDING_STATE, it.callRecording)
bundle.putBoolean(KEY_IS_MODERATOR, ConversationUtils.isParticipantOwnerOrModerator(it))
bundle.putBoolean(
BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_AUDIO,
participantPermissions.canPublishAudio()
)
bundle.putBoolean(BundleKeys.KEY_ROOM_ONE_TO_ONE, isOneToOneConversation())
bundle.putBoolean(
BundleKeys.KEY_PARTICIPANT_PERMISSION_CAN_PUBLISH_VIDEO,
participantPermissions.canPublishVideo()
)
if (isVoiceOnlyCall) {
bundle.putBoolean(KEY_CALL_VOICE_ONLY, true)
}
if (callWithoutNotification) {
bundle.putBoolean(BundleKeys.KEY_CALL_WITHOUT_NOTIFICATION, true)
}
if (it.objectType == ConversationEnums.ObjectType.ROOM) {
bundle.putBoolean(KEY_IS_BREAKOUT_ROOM, true)
}
val callIntent = Intent(this, CallActivity::class.java)
callIntent.putExtras(bundle)
return callIntent
} ?: run {
return null
}
}
override fun onClickReaction(chatMessage: ChatMessage, emoji: String) {
VibrationUtils.vibrateShort(context)
if (chatMessage.reactionsSelf?.contains(emoji) == true) {
chatViewModel.deleteReaction(roomToken, chatMessage, emoji)
} else {
chatViewModel.addReaction(roomToken, chatMessage, emoji)
}
}
override fun onLongClickReactions(chatMessage: ChatMessage) {
ShowReactionsDialog(
this,
roomToken,
chatMessage,
conversationUser,
participantPermissions.hasChatPermission(),
ncApi
).show()
}
override fun onOpenMessageActionsDialog(chatMessage: ChatMessage) {
openMessageActionsDialog(chatMessage)
}
override fun onMessageViewLongClick(view: View?, message: IMessage?) {
openMessageActionsDialog(message)
}
override fun onPreviewMessageLongClick(chatMessage: ChatMessage) {
onOpenMessageActionsDialog(chatMessage)
}
private fun openMessageActionsDialog(iMessage: IMessage?) {
val message = iMessage as ChatMessage
if (message.isTemporary) {
TempMessageActionsDialog(
this,
message
).show()
} else if (hasVisibleItems(message) &&
!isSystemMessage(message)
) {
MessageActionsDialog(
this,
message,
conversationUser,
currentConversation,
isShowMessageDeletionButton(message),
participantPermissions.hasChatPermission(),
spreedCapabilities
).show()
}
}
private fun isSystemMessage(message: ChatMessage): Boolean =
ChatMessage.MessageType.SYSTEM_MESSAGE == message.getCalculateMessageType()
fun deleteMessage(message: IMessage) {
if (!participantPermissions.hasChatPermission()) {
Log.w(
TAG,
"Deletion of message is skipped because of restrictions by permissions. " +
"This method should not have been called!"
)
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
} else {
var apiVersion = 1
// FIXME Fix API checking with guests?
if (conversationUser != null) {
apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1))
}
chatViewModel.deleteChatMessages(
credentials!!,
ApiUtils.getUrlForChatMessage(
apiVersion,
conversationUser?.baseUrl!!,
roomToken,
message.id!!
),
message.id!!
)
}
}
fun replyPrivately(message: IMessage?) {
val apiVersion =
ApiUtils.getConversationApiVersion(conversationUser!!, intArrayOf(ApiUtils.API_V4, 1))
val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom(
version = apiVersion,
baseUrl = conversationUser?.baseUrl!!,
roomType = "1",
invite = message?.user?.id?.substring(INVITE_LENGTH)
)
chatViewModel.createRoom(
credentials!!,
retrofitBucket.url!!,
retrofitBucket.queryMap!!
)
}
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, roomToken)
val intent = Intent(this, ConversationsListActivity::class.java)
intent.putExtras(bundle)
startActivity(intent)
}
fun remindMeLater(message: ChatMessage?) {
Log.d(TAG, "remindMeLater called")
val chatApiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(ApiUtils.API_V1, 1))
val bundle = bundleOf()
bundle.putString(KEY_ROOM_TOKEN, roomToken)
bundle.putString(BundleKeys.KEY_MESSAGE_ID, message!!.id)
bundle.putInt(BundleKeys.KEY_CHAT_API_VERSION, chatApiVersion)
binding.genericComposeView.apply {
val shouldDismiss = mutableStateOf(false)
setContent {
DateTimeCompose(bundle).GetDateTimeDialog(shouldDismiss, this@ChatActivity)
}
}
}
fun markAsUnread(message: IMessage?) {
val chatMessage = message as ChatMessage?
if (chatMessage!!.previousMessageId > NO_PREVIOUS_MESSAGE_ID) {
chatViewModel.setChatReadMarker(
credentials!!,
ApiUtils.getUrlForChatReadMarker(
ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(ApiUtils.API_V1)),
conversationUser?.baseUrl!!,
roomToken
),
chatMessage.previousMessageId
)
}
}
fun copyMessage(message: IMessage?) {
val clipboardManager =
getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipData = ClipData.newPlainText(
resources?.getString(R.string.nc_app_product_name),
message?.text
)
clipboardManager.setPrimaryClip(clipData)
}
fun translateMessage(message: IMessage?) {
val bundle = Bundle()
bundle.putString(BundleKeys.KEY_TRANSLATE_MESSAGE, message?.text)
val intent = Intent(this, TranslateActivity::class.java)
intent.putExtras(bundle)
startActivity(intent)
}
fun share(message: ChatMessage) {
val filename = message.selectedIndividualHashMap!!["name"]
path = applicationContext.cacheDir.absolutePath + "/" + filename
val shareUri = FileProvider.getUriForFile(
this,
BuildConfig.APPLICATION_ID,
File(path)
)
val shareIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_STREAM, shareUri)
type = Mimetype.IMAGE_PREFIX_GENERIC
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(Intent.createChooser(shareIntent, resources.getText(R.string.send_to)))
}
fun checkIfSharable(message: ChatMessage) {
val filename = message.selectedIndividualHashMap!!["name"]
path = applicationContext.cacheDir.absolutePath + "/" + filename
val file = File(context.cacheDir, filename!!)
if (file.exists()) {
share(message)
} else {
downloadFileToCache(message, false) {
share(message)
}
}
}
private fun showSaveToStorageWarning(message: ChatMessage) {
val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance(
message.selectedIndividualHashMap!!["name"]!!
)
saveFragment.show(
supportFragmentManager,
SaveToStorageDialogFragment.TAG
)
}
fun checkIfSaveable(message: ChatMessage) {
val filename = message.selectedIndividualHashMap!!["name"]
path = applicationContext.cacheDir.absolutePath + "/" + filename
val file = File(context.cacheDir, filename!!)
if (file.exists()) {
showSaveToStorageWarning(message)
} else {
downloadFileToCache(message, false) {
showSaveToStorageWarning(message)
}
}
}
fun shareToNotes(message: ChatMessage) {
val apiVersion = ApiUtils.getConversationApiVersion(
conversationUser!!,
intArrayOf(ApiUtils.API_V4, ApiUtils.API_V3, 1)
)
this.lifecycleScope.launch {
val noteToSelfConversation = chatViewModel.checkForNoteToSelf(
ApiUtils.getCredentials(conversationUser!!.username, conversationUser!!.token)!!,
ApiUtils.getUrlForNoteToSelf(
apiVersion,
conversationUser!!.baseUrl
)
)
if (noteToSelfConversation != null) {
var shareUri: Uri? = null
val data: HashMap<String?, String?>?
var metaData = ""
var objectId = ""
if (message.hasFileAttachment()) {
val filename = message.selectedIndividualHashMap!!["name"]
path = applicationContext.cacheDir.absolutePath + "/" + filename
shareUri = FileProvider.getUriForFile(
context,
BuildConfig.APPLICATION_ID,
File(path)
)
grantUriPermission(
applicationContext.packageName,
shareUri,
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} else if (message.hasGeoLocation()) {
data = message.messageParameters?.get("object")
objectId = data?.get("id")!!
val name = data["name"]!!
val lat = data["latitude"]!!
val lon = data["longitude"]!!
metaData =
"{\"type\":\"geo-location\",\"id\":\"geo:$lat,$lon\",\"latitude\":\"$lat\"," +
"\"longitude\":\"$lon\",\"name\":\"$name\"}"
}
shareToNotes(shareUri, noteToSelfConversation.token, message, objectId, metaData)
} else {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
}
}
private fun shareToNotes(
shareUri: Uri?,
roomToken: String,
message: ChatMessage,
objectId: String,
metaData: String
) {
val type = message.getCalculateMessageType()
when (type) {
ChatMessage.MessageType.VOICE_MESSAGE -> {
uploadFile(shareUri.toString(), true, token = roomToken)
showSnackBar(roomToken)
}
ChatMessage.MessageType.SINGLE_NC_ATTACHMENT_MESSAGE -> {
val caption = if (message.message != "{file}") message.message else ""
if (null != shareUri) {
try {
context.contentResolver.openInputStream(shareUri)?.close()
uploadFile(shareUri.toString(), false, caption!!, roomToken)
showSnackBar(roomToken)
} catch (e: java.lang.Exception) {
Log.w(TAG, "File corresponding to the uri does not exist $shareUri")
downloadFileToCache(message, false) {
uploadFile(shareUri.toString(), false, caption!!, roomToken)
showSnackBar(roomToken)
}
}
}
}
ChatMessage.MessageType.SINGLE_NC_GEOLOCATION_MESSAGE -> {
val apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1))
chatViewModel.shareLocationToNotes(
credentials!!,
ApiUtils.getUrlToSendLocation(apiVersion, conversationUser!!.baseUrl!!, roomToken),
"geo-location",
objectId,
metaData
)
showSnackBar(roomToken)
}
ChatMessage.MessageType.REGULAR_TEXT_MESSAGE -> {
val apiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1))
chatViewModel.shareToNotes(
credentials!!,
ApiUtils.getUrlForChat(apiVersion, conversationUser!!.baseUrl!!, roomToken),
message.message!!,
conversationUser!!.displayName!!
)
showSnackBar(roomToken)
}
else -> {}
}
}
fun showSnackBar(roomToken: String) {
val snackBar = Snackbar.make(binding.root, R.string.nc_message_sent, Snackbar.LENGTH_LONG)
snackBar.view.setOnClickListener {
openNoteToSelfConversation(roomToken)
}
snackBar.show()
}
fun openNoteToSelfConversation(noteToSelfRoomToken: String) {
val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, noteToSelfRoomToken)
val chatIntent = Intent(context, ChatActivity::class.java)
chatIntent.putExtras(bundle)
chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(chatIntent)
}
fun openInFilesApp(message: ChatMessage) {
val keyID = message.selectedIndividualHashMap!![PreviewMessageViewHolder.KEY_ID]
val link = message.selectedIndividualHashMap!!["link"]
val fileViewerUtils = FileViewerUtils(this, message.activeUser!!)
fileViewerUtils.openFileInFilesApp(link!!, keyID!!)
}
private fun hasVisibleItems(message: ChatMessage): Boolean =
!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 != ConversationEnums.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
private fun setMessageAsDeleted(message: IMessage?) {
val messageTemp = message as ChatMessage
messageTemp.isDeleted = true
messageTemp.message = getString(R.string.message_deleted_by_you)
messageTemp.isOneToOneConversation =
currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
messageTemp.activeUser = conversationUser
adapter?.update(messageTemp)
}
private fun setMessageAsEdited(message: IMessage?, newString: String) {
val messageTemp = message as ChatMessage
messageTemp.lastEditTimestamp = message.lastEditTimestamp
messageTemp.message = newString
val index = adapter?.getMessagePositionById(messageTemp.id)!!
if (index > 0) {
val adapterMsg = adapter?.items?.get(index)?.item as ChatMessage
messageTemp.parentMessageId = adapterMsg.parentMessageId
}
messageTemp.isOneToOneConversation =
currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
messageTemp.activeUser = conversationUser
adapter?.update(messageTemp)
}
private fun updateMessageInsideAdapter(message: IMessage?) {
message?.let {
val messageTemp = message as ChatMessage
// TODO is this needed?
messageTemp.isOneToOneConversation =
currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
messageTemp.activeUser = conversationUser
adapter?.update(messageTemp)
}
}
fun updateUiToAddReaction(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)
}
fun updateUiToDeleteReaction(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
if (message.reactions!![emoji]!! <= 0) {
message.reactions!!.remove(emoji)
}
message.reactionsSelf!!.remove(emoji)
adapter?.update(message)
}
private fun isShowMessageDeletionButton(message: ChatMessage): Boolean {
val isUserAllowedByPrivileges = userAllowedByPrivilages(message)
val isOlderThanSixHours = message
.createdAt
.before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_DELETE_MESSAGE))
val hasDeleteMessagesUnlimitedCapability = CapabilitiesUtil.hasSpreedFeatureCapability(
spreedCapabilities,
SpreedFeatures.DELETE_MESSAGES_UNLIMITED
)
return when {
!isUserAllowedByPrivileges -> false
!hasDeleteMessagesUnlimitedCapability && isOlderThanSixHours -> false
message.systemMessageType != ChatMessage.SystemMessageType.DUMMY -> false
message.isDeleted -> false
!CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.DELETE_MESSAGES) -> false
!participantPermissions.hasChatPermission() -> false
hasDeleteMessagesUnlimitedCapability -> true
else -> true
}
}
fun userAllowedByPrivilages(message: ChatMessage): Boolean {
if (conversationUser == null) return false
val isUserAllowedByPrivileges = if (message.actorId == conversationUser!!.userId) {
true
} else {
ConversationUtils.canModerate(currentConversation!!, spreedCapabilities)
}
return isUserAllowedByPrivileges
}
override fun hasContentFor(message: ChatMessage, type: Byte): Boolean =
when (type) {
CONTENT_TYPE_LOCATION -> message.hasGeoLocation()
CONTENT_TYPE_VOICE_MESSAGE -> message.isVoiceMessage
CONTENT_TYPE_POLL -> message.isPoll()
CONTENT_TYPE_LINK_PREVIEW -> message.isLinkPreview()
CONTENT_TYPE_SYSTEM_MESSAGE -> !TextUtils.isEmpty(message.systemMessage)
CONTENT_TYPE_UNREAD_NOTICE_MESSAGE -> message.id == UNREAD_MESSAGES_MARKER_ID.toString()
CONTENT_TYPE_CALL_STARTED -> message.id == "-2"
CONTENT_TYPE_DECK_CARD -> message.isDeckCard()
else -> false
}
private fun processMostRecentMessage(recent: ChatMessage) {
when (recent.systemMessageType) {
ChatMessage.SystemMessageType.CALL_STARTED -> {
if (!callStarted) {
messageInputViewModel.showCallStartedIndicator(recent, true)
callStarted = true
}
}
ChatMessage.SystemMessageType.CALL_ENDED,
ChatMessage.SystemMessageType.CALL_MISSED,
ChatMessage.SystemMessageType.CALL_TRIED,
ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE -> {
callStarted = false
messageInputViewModel.showCallStartedIndicator(recent, false)
}
else -> {}
}
}
@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 != ConversationEnums.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.API_V4, 1))
}
val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom(
version = apiVersion,
baseUrl = conversationUser?.baseUrl!!,
roomType = "1",
invite = userMentionClickEvent.userId
)
chatViewModel.createRoom(
credentials!!,
retrofitBucket.url!!,
retrofitBucket.queryMap!!
)
}
}
fun sendPictureFromCamIntent() {
if (!permissionUtil.isCameraPermissionGranted()) {
requestCameraPermissions()
} else {
startPickCameraIntentForResult.launch(TakePhotoActivity.createIntent(context))
}
}
fun sendVideoFromCamIntent() {
if (!permissionUtil.isCameraPermissionGranted()) {
requestCameraPermissions()
} else {
Intent(MediaStore.ACTION_VIDEO_CAPTURE).also { takeVideoIntent ->
takeVideoIntent.resolveActivity(packageManager)?.also {
val videoFile: File? = try {
val outputDir = context.cacheDir
val dateFormat = SimpleDateFormat(FILE_DATE_PATTERN, Locale.ROOT)
val date = dateFormat.format(Date())
val videoName = String.format(
context.resources.getString(R.string.nc_video_filename),
date
)
File("$outputDir/$videoName$VIDEO_SUFFIX")
} catch (e: IOException) {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
Log.e(TAG, "error while creating video file", e)
null
}
videoFile?.also {
videoURI = FileProvider.getUriForFile(context, context.packageName, it)
takeVideoIntent.putExtra(MediaStore.EXTRA_OUTPUT, videoURI)
startPickCameraIntentForResult.launch(takeVideoIntent)
}
}
}
}
}
fun createPoll() {
val pollVoteDialog = PollCreateDialogFragment.newInstance(
roomToken
)
pollVoteDialog.show(supportFragmentManager, TAG)
}
fun jumpToQuotedMessage(parentMessage: ChatMessage) {
var foundMessage = false
for (position in 0 until (adapter!!.items.size)) {
val currentItem = adapter?.items?.get(position)?.item
if (currentItem is ChatMessage && currentItem.id == parentMessage.id) {
layoutManager!!.scrollToPosition(position)
foundMessage = true
break
}
}
if (!foundMessage) {
Log.d(TAG, "quoted message with id " + parentMessage.id + " was not found in adapter")
startContextChatWindowForMessage(parentMessage.id)
}
}
override fun joinAudioCall() {
startACall(true, false)
}
override fun joinVideoCall() {
startACall(false, false)
}
private fun logConversationInfos(methodName: String) {
Log.d(TAG, " |-----------------------------------------------")
Log.d(TAG, " | method: $methodName")
Log.d(TAG, " | ChatActivity: " + System.identityHashCode(this).toString())
Log.d(TAG, " | roomToken: $roomToken")
Log.d(TAG, " | currentConversation?.displayName: ${currentConversation?.displayName}")
Log.d(TAG, " | sessionIdAfterRoomJoined: $sessionIdAfterRoomJoined")
Log.d(TAG, " |-----------------------------------------------")
}
fun shareMessageText(message: String) {
val sendIntent: Intent = Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, message)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, getString(R.string.share))
startActivity(shareIntent)
}
fun joinOneToOneConversation(userId: String) {
val apiVersion =
ApiUtils.getConversationApiVersion(conversationUser!!, intArrayOf(ApiUtils.API_V4, 1))
val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom(
version = apiVersion,
baseUrl = conversationUser?.baseUrl!!,
roomType = ROOM_TYPE_ONE_TO_ONE,
source = ACTOR_TYPE,
invite = userId
)
chatViewModel.createRoom(
credentials!!,
retrofitBucket.url!!,
retrofitBucket.queryMap!!
)
}
companion object {
val TAG = ChatActivity::class.simpleName
private const val CONTENT_TYPE_CALL_STARTED: Byte = 1
private const val CONTENT_TYPE_SYSTEM_MESSAGE: Byte = 2
private const val CONTENT_TYPE_UNREAD_NOTICE_MESSAGE: Byte = 3
private const val CONTENT_TYPE_LOCATION: Byte = 4
private const val CONTENT_TYPE_VOICE_MESSAGE: Byte = 5
private const val CONTENT_TYPE_POLL: Byte = 6
private const val CONTENT_TYPE_LINK_PREVIEW: Byte = 7
private const val CONTENT_TYPE_DECK_CARD: Byte = 8
private const val UNREAD_MESSAGES_MARKER_ID = -1
private const val GET_ROOM_INFO_DELAY_NORMAL: Long = 30000
private const val GET_ROOM_INFO_DELAY_LOBBY: Long = 5000
private const val MILLIS_250 = 250L
private const val AGE_THRESHOLD_FOR_DELETE_MESSAGE: Int = 21600000 // (6 hours in millis = 6 * 3600 * 1000)
private const val REQUEST_SHARE_FILE_PERMISSION: Int = 221
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 VOICE_MESSAGE_META_DATA = "{\"messageType\":\"voice-message\"}"
private const val FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss"
private const val VIDEO_SUFFIX = ".mp4"
private const val FULLY_OPAQUE_INT: Int = 255
private const val SEMI_TRANSPARENT_INT: Int = 99
private const val VOICE_MESSAGE_SEEKBAR_BASE = 1000
private const val NO_PREVIOUS_MESSAGE_ID: Int = -1
private const val TOOLBAR_AVATAR_RATIO = 1.5
private const val STATUS_SIZE_IN_DP = 9f
private const val HTTP_BAD_REQUEST = 400
private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404
private const val MESSAGE_PULL_LIMIT = 100
private const val INVITE_LENGTH = 6
private const val ACTOR_LENGTH = 6
private const val CHUNK_SIZE: Int = 10
private const val ONE_SECOND_IN_MILLIS = 1000
private const val WHITESPACE = " "
private const val COMMA = ", "
private const val TYPING_INDICATOR_ANIMATION_DURATION = 200L
private const val TYPING_INDICATOR_MAX_NAME_LENGTH = 14
private const val TYPING_INDICATOR_POSITION_VISIBLE = -18f
private const val TYPING_INDICATOR_POSITION_HIDDEN = -1f
private const val MILLISEC_15: Long = 15
private const val CURRENT_AUDIO_MESSAGE_KEY = "CURRENT_AUDIO_MESSAGE"
private const val CURRENT_AUDIO_POSITION_KEY = "CURRENT_AUDIO_POSITION"
private const val CURRENT_AUDIO_WAS_PLAYING_KEY = "CURRENT_AUDIO_PLAYING"
private const val RESUME_AUDIO_TAG = "RESUME_AUDIO_TAG"
private const val FIVE_MINUTES_IN_SECONDS: Long = 300
private const val ROOM_TYPE_ONE_TO_ONE = "1"
private const val ACTOR_TYPE = "users"
const val CONVERSATION_INTERNAL_ID = "CONVERSATION_INTERNAL_ID"
const val NO_OFFLINE_MESSAGES_FOUND = "NO_OFFLINE_MESSAGES_FOUND"
const val VOICE_MESSAGE_CONTINUOUS_BEFORE = -5
const val VOICE_MESSAGE_CONTINUOUS_AFTER = 5
const val VOICE_MESSAGE_PLAY_ADD_THRESHOLD = 0.1
const val VOICE_MESSAGE_MARK_PLAYED_FACTOR = 20
const val OUT_OF_OFFICE_ALPHA = 76
const val ZERO_INDEX = 0
const val ONE_INDEX = 1
}
}