talk-android/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
2024-02-05 22:46:30 +01:00

4703 lines
187 KiB
Kotlin

/*
* Nextcloud Talk application
*
* @author Mario Danic
* @author Marcel Hibbe
* @author Andy Scherzinger
* @author Tim Krüger
* @author Ezhil Shanmugham
* Copyright (C) 2021-2022 Tim Krüger <t@timkrueger.me>
* Copyright (C) 2021 Andy Scherzinger <info@andy-scherzinger.de>
* Copyright (C) 2021-2022 Marcel Hibbe <dev@mhibbe.de>
* Copyright (C) 2017-2019 Mario Danic <mario@lovelyhq.com>
* Copyright (C) 2023 Ezhil Shanmugham <ezhil56x.contact@gmail.com>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package com.nextcloud.talk.chat
import android.Manifest
import android.animation.ObjectAnimator
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.res.AssetFileDescriptor
import android.content.res.Resources
import android.database.Cursor
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.ColorDrawable
import android.graphics.drawable.Drawable
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaPlayer
import android.media.MediaRecorder
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.CountDownTimer
import android.os.Handler
import android.os.SystemClock
import android.provider.ContactsContract
import android.provider.MediaStore
import android.text.Editable
import android.text.InputFilter
import android.text.SpannableStringBuilder
import android.text.TextUtils
import android.text.TextWatcher
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.view.Menu
import android.view.MenuItem
import android.view.MotionEvent
import android.view.View
import android.view.View.GONE
import android.view.View.OnTouchListener
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.AccelerateInterpolator
import android.view.animation.AlphaAnimation
import android.view.animation.Animation
import android.view.animation.LinearInterpolator
import android.widget.AbsListView
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.PopupMenu
import android.widget.RelativeLayout
import android.widget.RelativeLayout.BELOW
import android.widget.RelativeLayout.LayoutParams
import android.widget.SeekBar
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.appcompat.view.ContextThemeWrapper
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.content.PermissionChecker
import androidx.core.content.PermissionChecker.PERMISSION_GRANTED
import androidx.core.graphics.drawable.toBitmap
import androidx.core.text.bold
import androidx.core.widget.doAfterTextChanged
import androidx.emoji2.text.EmojiCompat
import androidx.emoji2.widget.EmojiTextView
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.ViewModelProvider
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.flexbox.FlexboxLayout
import com.google.android.material.button.MaterialButton
import com.google.android.material.snackbar.Snackbar
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.CallStartedViewHolder
import com.nextcloud.talk.adapters.messages.CommonMessageInterface
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.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.callbacks.MentionAutocompleteCallback
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
import com.nextcloud.talk.conversationinfo.ConversationInfoActivity
import com.nextcloud.talk.conversationlist.ConversationsListActivity
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.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.domain.ConversationModel
import com.nextcloud.talk.models.domain.ConversationReadOnlyState
import com.nextcloud.talk.models.domain.ConversationType
import com.nextcloud.talk.models.domain.LobbyState
import com.nextcloud.talk.models.domain.ObjectType
import com.nextcloud.talk.models.domain.ReactionAddedModel
import com.nextcloud.talk.models.domain.ReactionDeletedModel
import com.nextcloud.talk.models.json.chat.ChatMessage
import com.nextcloud.talk.models.json.chat.ChatOCSSingleMessage
import com.nextcloud.talk.models.json.chat.ChatOverall
import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage
import com.nextcloud.talk.models.json.chat.ReadStatus
import com.nextcloud.talk.models.json.conversations.RoomOverall
import com.nextcloud.talk.models.json.generic.GenericOverall
import com.nextcloud.talk.models.json.mention.Mention
import com.nextcloud.talk.models.json.signaling.NCSignalingMessage
import com.nextcloud.talk.polls.ui.PollCreateDialogFragment
import com.nextcloud.talk.presenters.MentionAutocompletePresenter
import com.nextcloud.talk.remotefilebrowser.activities.RemoteFileBrowserActivity
import com.nextcloud.talk.repositories.reactions.ReactionsRepository
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.MicInputCloud
import com.nextcloud.talk.ui.StatusDrawable
import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet
import com.nextcloud.talk.ui.dialog.AttachmentDialog
import com.nextcloud.talk.ui.dialog.DateTimePickerFragment
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.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.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.ImageEmojiEditText
import com.nextcloud.talk.utils.MagicCharPolicy
import com.nextcloud.talk.utils.Mimetype
import com.nextcloud.talk.utils.NotificationUtils
import com.nextcloud.talk.utils.ParticipantPermissions
import com.nextcloud.talk.utils.UriUtils
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_ID
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.database.user.CapabilitiesUtilNew
import com.nextcloud.talk.utils.database.user.CurrentUserProviderNew
import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil
import com.nextcloud.talk.utils.rx.DisposableSet
import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder
import com.nextcloud.talk.utils.text.Spans
import com.nextcloud.talk.webrtc.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 com.vanniktech.emoji.EmojiPopup
import io.reactivex.Observer
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import io.reactivex.subjects.BehaviorSubject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import retrofit2.HttpException
import retrofit2.Response
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.Objects
import java.util.concurrent.ExecutionException
import javax.inject.Inject
import kotlin.collections.set
import kotlin.math.abs
import kotlin.math.log10
import kotlin.math.roundToInt
@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 currentUserProvider: CurrentUserProviderNew
@Inject
lateinit var reactionsRepository: ReactionsRepository
@Inject
lateinit var permissionUtil: PlatformPermissionUtil
@Inject
lateinit var dateUtils: DateUtils
@Inject
lateinit var viewModelFactory: ViewModelProvider.Factory
lateinit var chatViewModel: ChatViewModel
val editableBehaviorSubject = BehaviorSubject.createDefault(false)
val editedTextBehaviorSubject = BehaviorSubject.createDefault("")
private lateinit var editMessage: ChatMessage
override val view: View
get() = binding.root
val disposables = DisposableSet()
var sessionIdAfterRoomJoined: String? = null
lateinit var roomToken: String
var conversationUser: User? = null
private var roomPassword: String = ""
var credentials: String? = null
var currentConversation: ConversationModel? = null
private var globalLastKnownFutureMessageId = -1
private var globalLastKnownPastMessageId = -1
var adapter: TalkMessagesListAdapter<ChatMessage>? = null
private var mentionAutocomplete: Autocomplete<*>? = null
var layoutManager: LinearLayoutManager? = null
var pullChatMessagesPending = false
var newMessagesCount = 0
var startCallFromNotification: Boolean = false
var startCallFromRoomSwitch: Boolean = false
lateinit var roomId: String
var voiceOnly: Boolean = true
var isFirstMessagesProcessing = true
private var emojiPopup: EmojiPopup? = null
private lateinit var path: String
var myFirstMessage: CharSequence? = null
var checkingLobbyStatus: Boolean = false
private var conversationInfoMenuItem: MenuItem? = null
private var conversationVoiceCallMenuItem: MenuItem? = null
private var conversationVideoMenuItem: MenuItem? = null
private var conversationSharedItemsItem: MenuItem? = null
private var webSocketInstance: WebSocketInstance? = null
private var signalingMessageSender: SignalingMessageSender? = null
var getRoomInfoTimerHandler: Handler? = null
var pastPreconditionFailed = false
var futurePreconditionFailed = false
private val filesToUpload: MutableList<String> = ArrayList()
private lateinit var sharedText: String
var currentVoiceRecordFile: String = ""
var isVoiceRecordingLocked: Boolean = false
private var isVoicePreviewPlaying: Boolean = false
private var recorder: MediaRecorder? = null
private enum class MediaRecorderState {
INITIAL,
INITIALIZED,
CONFIGURED,
PREPARED,
RECORDING,
RELEASED,
ERROR
}
private var mediaRecorderState: MediaRecorderState = MediaRecorderState.INITIAL
private var voicePreviewMediaPlayer: MediaPlayer? = null
private var voicePreviewObjectAnimator: ObjectAnimator? = null
var mediaPlayer: MediaPlayer? = null
lateinit var mediaPlayerHandler: Handler
private var currentlyPlayedVoiceMessage: ChatMessage? = null
private lateinit var micInputAudioRecorder: AudioRecord
private var micInputAudioRecordThread: Thread? = null
private var isMicInputAudioThreadRunning: Boolean = false
private val bufferSize = AudioRecord.getMinBufferSize(
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
private var voiceRecordDuration = 0L
private var voiceRecordPauseTime = 0L
// messy workaround for a mediaPlayer bug, don't delete
private var lastRecordMediaPosition: Int = 0
private var lastRecordedSeeked: Boolean = false
private lateinit var participantPermissions: ParticipantPermissions
private var videoURI: Uri? = null
private val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val intent = Intent(this@ChatActivity, ConversationsListActivity::class.java)
intent.putExtras(Bundle())
startActivity(intent)
}
}
var typingTimer: CountDownTimer? = null
var typedWhileTypingTimerIsRunning: Boolean = false
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]
binding.progressBar.visibility = View.VISIBLE
onBackPressedDispatcher.addCallback(this, onBackPressedCallback)
initObservers()
}
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
roomId = extras?.getString(KEY_ROOM_ID).orEmpty()
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
context.getSharedPreferences(localClassName, MODE_PRIVATE).apply {
val text = getString(roomToken, "")
val cursor = getInt(roomToken + CURSOR_KEY, 0)
binding.messageInputView.messageInput.setText(text)
binding.messageInputView.messageInput.setSelection(cursor)
}
this.lifecycle.addObserver(AudioUtils)
}
override fun onStop() {
super.onStop()
active = false
stopPreviewVoicePlaying()
if (isMicInputAudioThreadRunning) {
stopMicInputRecordingAnimation()
}
if (mediaRecorderState == MediaRecorderState.RECORDING) {
stopAudioRecording()
}
if (currentlyPlayedVoiceMessage != null) {
stopMediaPlayer(currentlyPlayedVoiceMessage!!)
}
val text = binding.messageInputView.messageInput.text.toString()
val cursor = binding.messageInputView.messageInput.selectionStart
val previous = context.getSharedPreferences(localClassName, MODE_PRIVATE).getString(roomToken, "null")
if (text != previous) {
context.getSharedPreferences(localClassName, MODE_PRIVATE).edit().apply {
putString(roomToken, text)
putInt(roomToken + CURSOR_KEY, cursor)
apply()
}
}
this.lifecycle.removeObserver(AudioUtils)
}
@Suppress("LongMethod")
private fun initObservers() {
chatViewModel.getRoomViewState.observe(this) { state ->
when (state) {
is ChatViewModel.GetRoomSuccessState -> {
currentConversation = state.conversationModel
logConversationInfos("GetRoomSuccessState")
if (adapter == null) {
initAdapter()
binding.messagesListView.setAdapter(adapter)
}
layoutManager = binding.messagesListView.layoutManager as LinearLayoutManager?
loadAvatarForStatusBar()
setActionBarTitle()
participantPermissions = ParticipantPermissions(conversationUser!!, currentConversation!!)
setupSwipeToReply()
setupMentionAutocomplete()
checkShowCallButtons()
checkShowMessageInputView()
checkLobbyState()
if (!validSessionId()) {
joinRoomWithPassword()
} else {
Log.d(TAG, "already inConversation. joinRoomWithPassword is skipped")
}
val delayForRecursiveCall = if (shouldShowLobby()) {
GET_ROOM_INFO_DELAY_LOBBY
} else {
GET_ROOM_INFO_DELAY_NORMAL
}
if (getRoomInfoTimerHandler == null) {
getRoomInfoTimerHandler = Handler()
}
getRoomInfoTimerHandler?.postDelayed(
{
chatViewModel.getRoom(conversationUser!!, roomToken)
},
delayForRecursiveCall
)
}
is ChatViewModel.GetRoomErrorState -> {
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().currentRoomId = currentConversation!!.roomId
ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = currentConversation!!.token
ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser
logConversationInfos("joinRoomWithPassword#onNext")
if (isFirstMessagesProcessing) {
pullChatMessages(false)
} else {
pullChatMessages(true, false)
}
if (webSocketInstance != null) {
webSocketInstance?.joinRoomWithRoomTokenAndSession(
roomToken,
sessionIdAfterRoomJoined
)
}
if (startCallFromNotification != null && startCallFromNotification) {
startCallFromNotification = false
startACall(voiceOnly, false)
}
if (startCallFromRoomSwitch) {
startCallFromRoomSwitch = false
startACall(voiceOnly, true)
}
}
is ChatViewModel.JoinRoomErrorState -> {
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
else -> {}
}
}
}
@Suppress("Detekt.TooGenericExceptionCaught")
override fun onResume() {
super.onResume()
logConversationInfos("onResume")
pullChatMessagesPending = false
setupWebsocket()
webSocketInstance?.getSignalingMessageReceiver()?.addListener(localParticipantMessageListener)
webSocketInstance?.getSignalingMessageReceiver()?.addListener(conversationMessageListener)
if (conversationUser?.userId != "?" &&
CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "mention-flag")
) {
binding.chatToolbar.setOnClickListener { v -> showConversationInfoScreen() }
}
initSmileyKeyboardToggler()
themeMessageInputView()
cancelNotificationsForCurrentConversation()
chatViewModel.getRoom(conversationUser!!, roomToken)
actionBar?.show()
setupSwipeToReply()
binding.popupBubbleView.setRecyclerView(binding.messagesListView)
binding.popupBubbleView.setPopupBubbleListener { context ->
if (newMessagesCount != 0) {
val scrollPosition = if (newMessagesCount - 1 < 0) {
0
} else {
newMessagesCount - 1
}
Handler().postDelayed(
{
binding.messagesListView.smoothScrollToPosition(scrollPosition)
},
NEW_MESSAGES_POPUP_BUBBLE_DELAY
)
}
}
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.popupBubbleView) }
binding.messageInputView.setPadding(0, 0, 0, 0)
binding.messagesListView.addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
if (newState == AbsListView.OnScrollListener.SCROLL_STATE_IDLE) {
if (layoutManager!!.findFirstCompletelyVisibleItemPosition() > 0) {
binding.scrollDownButton.visibility = View.VISIBLE
} else {
binding.scrollDownButton.visibility = View.GONE
}
if (newMessagesCount != 0 && layoutManager != null) {
if (layoutManager!!.findFirstCompletelyVisibleItemPosition() < newMessagesCount) {
newMessagesCount = 0
if (binding.popupBubbleView.isShown == true) {
binding.popupBubbleView.hide()
}
}
}
}
}
})
initMessageInputView()
loadAvatarForStatusBar()
setActionBarTitle()
viewThemeUtils.material.colorToolbarOverflowIcon(binding.chatToolbar)
}
private fun initMessageInputView() {
val filters = arrayOfNulls<InputFilter>(1)
val lengthFilter = CapabilitiesUtilNew.getMessageMaxLength(conversationUser)
if (editableBehaviorSubject.value!!) {
val editableText = Editable.Factory.getInstance().newEditable(editMessage.message)
binding.messageInputView.inputEditText.text = editableText
binding.messageInputView.inputEditText.setSelection(editableText.length)
}
filters[0] = InputFilter.LengthFilter(lengthFilter)
binding.messageInputView.inputEditText?.filters = filters
binding.messageInputView.inputEditText?.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {
// unused atm
}
@Suppress("Detekt.TooGenericExceptionCaught")
override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) {
updateOwnTypingStatus(s)
if (s.length >= lengthFilter) {
binding.messageInputView.inputEditText?.error = String.format(
Objects.requireNonNull<Resources>(resources).getString(R.string.nc_limit_hit),
lengthFilter.toString()
)
} else {
binding.messageInputView.inputEditText?.error = null
}
val editable = binding.messageInputView.inputEditText?.editableText
editedTextBehaviorSubject.onNext(editable.toString().trim())
if (editable != null && binding.messageInputView.inputEditText != null) {
val mentionSpans = editable.getSpans(
0,
binding.messageInputView.inputEditText!!.length(),
Spans.MentionChipSpan::class.java
)
var mentionSpan: Spans.MentionChipSpan
for (i in mentionSpans.indices) {
mentionSpan = mentionSpans[i]
if (start >= editable.getSpanStart(mentionSpan) &&
start < editable.getSpanEnd(mentionSpan)
) {
if (editable.subSequence(
editable.getSpanStart(mentionSpan),
editable.getSpanEnd(mentionSpan)
).toString().trim { it <= ' ' } != mentionSpan.label
) {
editable.removeSpan(mentionSpan)
}
}
}
}
}
override fun afterTextChanged(s: Editable) {
// unused atm
}
})
// Image keyboard support
// See: https://developer.android.com/guide/topics/text/image-keyboard
(binding.messageInputView.inputEditText as ImageEmojiEditText).onCommitContentListener = {
uploadFile(it.toString(), false)
}
initVoiceRecordButton()
if (editableBehaviorSubject.value!!) {
binding.messageInputView.messageSendButton.visibility = View.GONE
binding.messageInputView.recordAudioButton.visibility = View.GONE
binding.messageInputView.editMessageButton.visibility = View.VISIBLE
binding.messageInputView.clearEditMessage.visibility = View.VISIBLE
}
if (sharedText.isNotEmpty()) {
binding.messageInputView.inputEditText?.setText(sharedText)
}
binding.messageInputView.setAttachmentsListener {
AttachmentDialog(this, this).show()
}
binding.messageInputView.button?.setOnClickListener {
submitMessage(false)
}
binding.messageInputView.editMessageButton.setOnClickListener {
if(editMessage.message == editedTextBehaviorSubject.value!!){
clearEditUI()
return@setOnClickListener
}
editMessageAPI(editMessage, editedMessageText = editedTextBehaviorSubject.value!!)
}
binding.messageInputView.clearEditMessage.setOnClickListener {
clearEditUI()
}
if (CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "silent-send")) {
binding.messageInputView.button?.setOnLongClickListener {
showSendButtonMenu()
true
}
}
binding.messageInputView.button?.contentDescription =
resources?.getString(R.string.nc_description_send_message_button)
}
private fun editMessageAPI(message: ChatMessage, editedMessageText: String) {
var apiVersion = 1
// FIXME Fix API checking with guests?
if (conversationUser != null) {
apiVersion = ApiUtils.getChatApiVersion(conversationUser, intArrayOf(1))
}
ncApi.editChatMessage(
credentials,
ApiUtils.getUrlForChatMessage(
apiVersion,
conversationUser?.baseUrl,
roomToken,
message?.id
), editedMessageText
)?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<ChatOCSSingleMessage> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(t: ChatOCSSingleMessage) {
//unused atm
}
override fun onError(e: Throwable) {
}
override fun onComplete() {
clearEditUI()
}
})
// remove last item from list
}
private fun clearEditUI() {
binding.messageInputView.editMessageButton.visibility = GONE
binding.messageInputView.clearEditMessage.visibility = View.GONE
editableBehaviorSubject.onNext(false)
binding.messageInputView.inputEditText.setText("")
}
private fun themeMessageInputView() {
binding.messageInputView.button?.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) }
binding.messageInputView.findViewById<ImageButton>(R.id.cancelReplyButton)?.setOnClickListener {
cancelReply()
}
binding.messageInputView.findViewById<ImageButton>(R.id.cancelReplyButton)?.let {
viewThemeUtils.platform
.themeImageButton(it)
}
binding.messageInputView.findViewById<MaterialButton>(R.id.playPauseBtn)?.let {
viewThemeUtils.material.colorMaterialButtonText(it)
}
binding.messageInputView.findViewById<SeekBar>(R.id.seekbar)?.let {
viewThemeUtils.platform.themeHorizontalSeekBar(it)
}
binding.messageInputView.findViewById<ImageView>(R.id.deleteVoiceRecording)?.let {
viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
}
binding.messageInputView.findViewById<ImageView>(R.id.sendVoiceRecording)?.let {
viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
}
binding.messageInputView.findViewById<ImageView>(R.id.microphoneEnabledInfo)?.let {
viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
}
binding.messageInputView.findViewById<LinearLayout>(R.id.voice_preview_container)?.let {
viewThemeUtils.talk.themeOutgoingMessageBubble(it, true, false)
}
binding.messageInputView.findViewById<MicInputCloud>(R.id.micInputCloud)?.let {
viewThemeUtils.talk.themeMicInputCloud(it)
}
}
private fun setupActionBar() {
setSupportActionBar(binding.chatToolbar)
binding.chatToolbar.setNavigationOnClickListener {
onBackPressedDispatcher.onBackPressed()
}
supportActionBar?.setDisplayHomeAsUpEnabled(true)
supportActionBar?.setDisplayShowHomeEnabled(true)
supportActionBar?.setIcon(ColorDrawable(resources!!.getColor(R.color.transparent, null)))
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) {
pausePlayback(message)
} else {
val retrieved = appPreferences.getWaveFormFromFile(filename)
if (retrieved.isEmpty()) {
setUpWaveform(message)
} else {
startPlayback(message)
}
}
} else {
Log.d(TAG, "Downloaded to cache")
downloadFileToCache(message, true) {
setUpWaveform(message)
}
}
}
}
private fun setUpWaveform(message: ChatMessage) {
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(message)
}
}
} else {
startPlayback(message)
}
}
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_CALL_STARTED,
CallStartedViewHolder::class.java,
payload,
R.layout.call_started_message,
CallStartedViewHolder::class.java,
payload,
R.layout.call_started_message,
this
)
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
)
return messageHolders
}
@SuppressLint("ClickableViewAccessibility")
private fun initVoiceRecordButton() {
if (!isVoiceRecordingLocked) {
if (!editableBehaviorSubject.value!!) {
if (binding.messageInputView.messageInput.text!!.isNotEmpty()) {
showMicrophoneButton(false)
} else {
showMicrophoneButton(true)
}
}
} else if (mediaRecorderState == MediaRecorderState.RECORDING) {
binding.messageInputView.playPauseBtn.visibility = View.GONE
binding.messageInputView.seekBar.visibility = View.GONE
} else {
showVoiceRecordingLockedInterface(true)
showPreviewVoiceRecording(true)
stopMicInputRecordingAnimation()
binding.messageInputView.micInputCloud.setState(MicInputCloud.ViewState.PAUSED_STATE)
}
isVoicePreviewPlaying = false
binding.messageInputView.messageInput.doAfterTextChanged {
if (!editableBehaviorSubject.value!!) {
if (binding.messageInputView.messageInput.text?.isEmpty() == true) {
showMicrophoneButton(true)
} else {
showMicrophoneButton(false)
}
}
}
var sliderInitX = 0F
var downX = 0f
var originY = 0f
var deltaX = 0f
var deltaY = 0f
var voiceRecordStartTime = 0L
var voiceRecordEndTime = 0L
// this is so that the seekbar is no longer draggable
binding.messageInputView.seekBar.setOnTouchListener(OnTouchListener { _, _ -> true })
binding.messageInputView.micInputCloud.setOnClickListener {
if (mediaRecorderState == MediaRecorderState.RECORDING) {
recorder?.stop()
mediaRecorderState = MediaRecorderState.INITIAL
stopMicInputRecordingAnimation()
showPreviewVoiceRecording(true)
} else {
stopPreviewVoicePlaying()
initMediaRecorder(currentVoiceRecordFile)
startMicInputRecordingAnimation()
showPreviewVoiceRecording(false)
}
}
binding.messageInputView.deleteVoiceRecording.setOnClickListener {
stopAndDiscardAudioRecording()
endVoiceRecordingUI()
stopMicInputRecordingAnimation()
binding.messageInputView.slideToCancelDescription.x = sliderInitX
}
binding.messageInputView.sendVoiceRecording.setOnClickListener {
stopAndSendAudioRecording()
endVoiceRecordingUI()
stopMicInputRecordingAnimation()
binding.messageInputView.slideToCancelDescription.x = sliderInitX
}
binding.messageInputView.playPauseBtn.setOnClickListener {
Log.d(TAG, "is voice preview playing $isVoicePreviewPlaying")
if (isVoicePreviewPlaying) {
Log.d(TAG, "Paused")
pausePreviewVoicePlaying()
binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(
context,
R.drawable
.ic_baseline_play_arrow_voice_message_24
)
isVoicePreviewPlaying = false
} else {
Log.d(TAG, "Started")
startPreviewVoicePlaying()
binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(
context,
R.drawable
.ic_baseline_pause_voice_message_24
)
isVoicePreviewPlaying = true
}
}
binding.messageInputView.recordAudioButton.setOnTouchListener(object : OnTouchListener {
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
v?.performClick() // ?????????
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
if (!isRecordAudioPermissionGranted()) {
requestRecordAudioPermissions()
return true
}
if (!permissionUtil.isFilesPermissionGranted()) {
UploadAndShareFilesWorker.requestStoragePermission(this@ChatActivity)
return true
}
voiceRecordStartTime = System.currentTimeMillis()
setVoiceRecordFileName()
startAudioRecording(currentVoiceRecordFile)
downX = event.x
originY = event.y
showRecordAudioUi(true)
}
MotionEvent.ACTION_CANCEL -> {
Log.d(TAG, "ACTION_CANCEL. same as for UP")
if (mediaRecorderState != MediaRecorderState.RECORDING || !isRecordAudioPermissionGranted()) {
return true
}
stopAndDiscardAudioRecording()
endVoiceRecordingUI()
binding.messageInputView.slideToCancelDescription.x = sliderInitX
}
MotionEvent.ACTION_UP -> {
Log.d(TAG, "ACTION_UP. stop recording??")
if (mediaRecorderState != MediaRecorderState.RECORDING ||
!isRecordAudioPermissionGranted() ||
isVoiceRecordingLocked
) {
return true
}
showRecordAudioUi(false)
voiceRecordEndTime = System.currentTimeMillis()
voiceRecordDuration = voiceRecordEndTime - voiceRecordStartTime
if (voiceRecordDuration < MINIMUM_VOICE_RECORD_DURATION) {
Log.d(TAG, "voiceRecordDuration: $voiceRecordDuration")
Snackbar.make(
binding.root,
context.getString(R.string.nc_voice_message_hold_to_record_info),
Snackbar.LENGTH_SHORT
).show()
stopAndDiscardAudioRecording()
return true
} else {
voiceRecordStartTime = 0L
voiceRecordEndTime = 0L
stopAndSendAudioRecording()
}
binding.messageInputView.slideToCancelDescription.x = sliderInitX
}
MotionEvent.ACTION_MOVE -> {
Log.d(TAG, "ACTION_MOVE.")
if (mediaRecorderState != MediaRecorderState.RECORDING || !isRecordAudioPermissionGranted()) {
return true
}
showRecordAudioUi(true)
val movedX: Float = event.x
val movedY: Float = event.y
deltaX = movedX - downX
deltaY = movedY - originY
binding.voiceRecordingLock.translationY.let {
if (it < VOICE_RECORD_LOCK_BUTTON_Y) {
Log.d(TAG, "Voice Recording Locked")
isVoiceRecordingLocked = true
showVoiceRecordingLocked(true)
showVoiceRecordingLockedInterface(true)
startMicInputRecordingAnimation()
} else if (deltaY < 0f) {
binding.voiceRecordingLock.translationY = deltaY
}
}
// only allow slide to left
binding.messageInputView.slideToCancelDescription.x.let {
if (sliderInitX == 0.0F) {
sliderInitX = it
}
if (it > sliderInitX) {
binding.messageInputView.slideToCancelDescription.x = sliderInitX
}
}
binding.messageInputView.slideToCancelDescription.x.let {
if (it < VOICE_RECORD_CANCEL_SLIDER_X) {
Log.d(TAG, "stopping recording because slider was moved to left")
stopAndDiscardAudioRecording()
endVoiceRecordingUI()
binding.messageInputView.slideToCancelDescription.x = sliderInitX
return true
} else {
binding.messageInputView.slideToCancelDescription.x = it + deltaX
downX = movedX
}
}
}
}
return v?.onTouchEvent(event) ?: true
}
})
}
private fun showPreviewVoiceRecording(value: Boolean) {
val micInputCloudLayoutParams: LayoutParams = binding.messageInputView.micInputCloud
.layoutParams as LayoutParams
val deleteVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.deleteVoiceRecording
.layoutParams as LayoutParams
val sendVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.sendVoiceRecording
.layoutParams as LayoutParams
if (value) {
voiceRecordPauseTime = binding.messageInputView.audioRecordDuration.base - SystemClock.elapsedRealtime()
binding.messageInputView.audioRecordDuration.stop()
binding.messageInputView.audioRecordDuration.visibility = View.GONE
binding.messageInputView.playPauseBtn.visibility = View.VISIBLE
binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(
context,
R.drawable.ic_baseline_play_arrow_voice_message_24
)
binding.messageInputView.seekBar.visibility = View.VISIBLE
binding.messageInputView.seekBar.progress = 0
binding.messageInputView.seekBar.max = 0
micInputCloudLayoutParams.removeRule(BELOW)
micInputCloudLayoutParams.addRule(BELOW, R.id.voice_preview_container)
deleteVoiceRecordingLayoutParams.removeRule(BELOW)
deleteVoiceRecordingLayoutParams.addRule(BELOW, R.id.voice_preview_container)
sendVoiceRecordingLayoutParams.removeRule(BELOW)
sendVoiceRecordingLayoutParams.addRule(BELOW, R.id.voice_preview_container)
} else {
binding.messageInputView.audioRecordDuration.base = SystemClock.elapsedRealtime()
binding.messageInputView.audioRecordDuration.start()
binding.messageInputView.playPauseBtn.visibility = View.GONE
binding.messageInputView.seekBar.visibility = View.GONE
binding.messageInputView.audioRecordDuration.visibility = View.VISIBLE
micInputCloudLayoutParams.removeRule(BELOW)
micInputCloudLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
deleteVoiceRecordingLayoutParams.removeRule(BELOW)
deleteVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
sendVoiceRecordingLayoutParams.removeRule(BELOW)
sendVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
}
}
private fun initPreviewVoiceRecording() {
voicePreviewMediaPlayer = MediaPlayer().apply {
setDataSource(currentVoiceRecordFile)
prepare()
setOnPreparedListener {
binding.messageInputView.seekBar.progress = 0
binding.messageInputView.seekBar.max = it.duration
voicePreviewObjectAnimator = ObjectAnimator.ofInt(
binding.messageInputView.seekBar,
"progress",
0,
it.duration
).apply {
duration = it.duration.toLong()
interpolator = LinearInterpolator()
}
voicePreviewMediaPlayer!!.start()
voicePreviewObjectAnimator!!.start()
}
setOnCompletionListener {
stopPreviewVoicePlaying()
}
}
}
private fun startPreviewVoicePlaying() {
Log.d(TAG, "started preview voice recording")
if (voicePreviewMediaPlayer == null) {
initPreviewVoiceRecording()
} else {
voicePreviewMediaPlayer!!.start()
voicePreviewObjectAnimator!!.resume()
}
}
private fun pausePreviewVoicePlaying() {
Log.d(TAG, "paused preview voice recording")
voicePreviewMediaPlayer!!.pause()
voicePreviewObjectAnimator!!.pause()
}
private fun stopPreviewVoicePlaying() {
if (voicePreviewMediaPlayer != null) {
isVoicePreviewPlaying = false
binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(context, R.drawable.ic_refresh)
voicePreviewObjectAnimator!!.end()
voicePreviewObjectAnimator = null
binding.messageInputView.seekBar.clearAnimation()
voicePreviewMediaPlayer!!.stop()
voicePreviewMediaPlayer!!.release()
voicePreviewMediaPlayer = null
}
}
private fun endVoiceRecordingUI() {
stopPreviewVoicePlaying()
showRecordAudioUi(false)
binding.voiceRecordingLock.translationY = 0f
isVoiceRecordingLocked = false
showVoiceRecordingLocked(false)
showVoiceRecordingLockedInterface(false)
stopMicInputRecordingAnimation()
}
private fun showVoiceRecordingLocked(value: Boolean) {
if (value) {
binding.voiceRecordingLock.setImageDrawable(
ContextCompat.getDrawable(context, R.drawable.ic_lock_grey600_24px)
)
binding.voiceRecordingLock.alpha = 1f
binding.voiceRecordingLock.animate().alpha(0f).setDuration(VOICE_RECORDING_LOCK_ANIMATION_DURATION.toLong())
.setInterpolator(AccelerateInterpolator()).start()
} else {
binding.voiceRecordingLock.setImageDrawable(
ContextCompat.getDrawable(context, R.drawable.ic_lock_open_grey600_24dp)
)
binding.voiceRecordingLock.alpha = 1f
}
}
private fun showVoiceRecordingLockedInterface(value: Boolean) {
val audioDurationLayoutParams: LayoutParams = binding.messageInputView.audioRecordDuration
.layoutParams as LayoutParams
val micInputCloudLayoutParams: LayoutParams = binding.messageInputView.micInputCloud
.layoutParams as LayoutParams
val deleteVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.deleteVoiceRecording
.layoutParams as LayoutParams
val sendVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.sendVoiceRecording
.layoutParams as LayoutParams
val standardQuarterMargin = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
resources.getDimension(R.dimen.standard_quarter_margin),
resources
.displayMetrics
).toInt()
binding.messageInputView.button.isEnabled = true
if (value) {
binding.messageInputView.slideToCancelDescription.visibility = View.GONE
binding.messageInputView.deleteVoiceRecording.visibility = View.VISIBLE
binding.messageInputView.sendVoiceRecording.visibility = View.VISIBLE
binding.messageInputView.micInputCloud.visibility = View.VISIBLE
binding.messageInputView.recordAudioButton.visibility = View.GONE
binding.messageInputView.microphoneEnabledInfo.clearAnimation()
binding.messageInputView.microphoneEnabledInfo.visibility = View.GONE
binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.GONE
binding.messageInputView.recordAudioButton.visibility = View.GONE
micInputCloudLayoutParams.removeRule(BELOW)
micInputCloudLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
deleteVoiceRecordingLayoutParams.removeRule(BELOW)
deleteVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
sendVoiceRecordingLayoutParams.removeRule(BELOW)
sendVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration)
audioDurationLayoutParams.removeRule(RelativeLayout.CENTER_VERTICAL)
audioDurationLayoutParams.removeRule(RelativeLayout.END_OF)
audioDurationLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL, R.bool.value_true)
audioDurationLayoutParams.setMargins(0, standardQuarterMargin, 0, 0)
} else {
binding.messageInputView.deleteVoiceRecording.visibility = View.GONE
binding.messageInputView.micInputCloud.visibility = View.GONE
binding.messageInputView.recordAudioButton.visibility = View.VISIBLE
binding.messageInputView.sendVoiceRecording.visibility = View.GONE
binding.messageInputView.playPauseBtn.visibility = View.GONE
binding.messageInputView.seekBar.visibility = View.GONE
audioDurationLayoutParams.addRule(RelativeLayout.CENTER_VERTICAL, R.bool.value_true)
audioDurationLayoutParams.addRule(RelativeLayout.END_OF, R.id.microphoneEnabledInfo)
audioDurationLayoutParams.removeRule(RelativeLayout.CENTER_HORIZONTAL)
audioDurationLayoutParams.setMargins(0, 0, 0, 0)
}
}
private fun initSmileyKeyboardToggler() {
val smileyButton = binding.messageInputView.findViewById<ImageButton>(R.id.smileyButton)
emojiPopup = binding.messageInputView.inputEditText?.let {
EmojiPopup(
rootView = binding.root,
editText = it,
onEmojiPopupShownListener = {
if (resources != null) {
smileyButton?.setImageDrawable(
ContextCompat.getDrawable(context, R.drawable.ic_baseline_keyboard_24)
)
}
},
onEmojiPopupDismissListener = {
smileyButton?.setImageDrawable(
ContextCompat.getDrawable(context, R.drawable.ic_insert_emoticon_black_24dp)
)
},
onEmojiClickListener = {
binding.messageInputView.inputEditText?.editableText?.append(" ")
}
)
}
smileyButton?.setOnClickListener {
emojiPopup?.toggle()
}
}
@Suppress("MagicNumber", "LongMethod")
private fun updateTypingIndicator() {
fun ellipsize(text: String): String {
return 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
if (participantNames.size > 0) {
binding.typingIndicatorWrapper.animate()
.translationY(binding.messageInputView.y - DisplayUtils.convertDpToPixel(18f, context))
.setInterpolator(AccelerateDecelerateInterpolator())
.duration = TYPING_INDICATOR_ANIMATION_DURATION
} else {
if (binding.typingIndicator.lineCount == 1) {
binding.typingIndicatorWrapper.animate()
.translationY(binding.messageInputView.y)
.setInterpolator(AccelerateDecelerateInterpolator())
.duration = TYPING_INDICATOR_ANIMATION_DURATION
} else if (binding.typingIndicator.lineCount == 2) {
binding.typingIndicatorWrapper.animate()
.translationY(binding.messageInputView.y + DisplayUtils.convertDpToPixel(15f, context))
.setInterpolator(AccelerateDecelerateInterpolator())
.duration = TYPING_INDICATOR_ANIMATION_DURATION
}
}
}
}
fun updateOwnTypingStatus(typedText: CharSequence) {
fun sendStartTypingSignalingMessage() {
for ((sessionId, _) in webSocketInstance?.getUserMap()!!) {
val ncSignalingMessage = NCSignalingMessage()
ncSignalingMessage.to = sessionId
ncSignalingMessage.type = TYPING_STARTED_SIGNALING_MESSAGE_TYPE
signalingMessageSender!!.send(ncSignalingMessage)
}
}
if (isTypingStatusEnabled()) {
if (typedText.isEmpty()) {
sendStopTypingMessage()
} else if (typingTimer == null) {
sendStartTypingSignalingMessage()
typingTimer = object : CountDownTimer(
TYPING_DURATION_TO_SEND_NEXT_TYPING_MESSAGE,
TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE
) {
override fun onTick(millisUntilFinished: Long) {
// unused
}
override fun onFinish() {
if (typedWhileTypingTimerIsRunning) {
sendStartTypingSignalingMessage()
cancel()
start()
typedWhileTypingTimerIsRunning = false
} else {
sendStopTypingMessage()
}
}
}.start()
} else {
typedWhileTypingTimerIsRunning = true
}
}
}
private fun sendStopTypingMessage() {
if (isTypingStatusEnabled()) {
typingTimer = null
typedWhileTypingTimerIsRunning = false
for ((sessionId, _) in webSocketInstance?.getUserMap()!!) {
val ncSignalingMessage = NCSignalingMessage()
ncSignalingMessage.to = sessionId
ncSignalingMessage.type = TYPING_STOPPED_SIGNALING_MESSAGE_TYPE
signalingMessageSender!!.send(ncSignalingMessage)
}
}
}
private fun isTypingStatusEnabled(): Boolean {
return webSocketInstance != null &&
!CapabilitiesUtilNew.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) {
replyToMessage(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(BitmapDrawable(resources, bitmap))
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)
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 == ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
private fun isGroupConversation() =
currentConversation != null && currentConversation?.type != null &&
currentConversation?.type == ConversationType.ROOM_GROUP_CALL
private fun isPublicConversation() =
currentConversation != null && currentConversation?.type != null &&
currentConversation?.type == ConversationType.ROOM_PUBLIC_CALL
private fun switchToRoom(token: String, startCallAfterRoomSwitch: Boolean, isVoiceOnlyCall: Boolean) {
if (conversationUser != null) {
runOnUiThread {
if (currentConversation?.objectType == ObjectType.ROOM) {
Snackbar.make(
binding.root,
context.resources.getString(R.string.switch_to_main_room),
Snackbar.LENGTH_LONG
).show()
} else {
Snackbar.make(
binding.root,
context.resources.getString(R.string.switch_to_breakout_room),
Snackbar.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 showSendButtonMenu() {
val popupMenu = PopupMenu(
ContextThemeWrapper(this, R.style.ChatSendButtonMenu),
binding.messageInputView.button,
Gravity.END
)
popupMenu.inflate(R.menu.chat_send_menu)
popupMenu.setOnMenuItemClickListener { item: MenuItem ->
when (item.itemId) {
R.id.send_without_notification -> submitMessage(true)
}
true
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
popupMenu.setForceShowIcon(true)
}
popupMenu.show()
}
private fun showCallButtonMenu(isVoiceOnlyCall: Boolean) {
val anchor: View? = if (isVoiceOnlyCall) {
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()
}
}
private fun startPlayback(message: ChatMessage) {
if (!active) {
// don't begin to play voice message if screen is not visible anymore.
// this situation might happen if file is downloading but user already left the chatview.
// If user returns to chatview, the old chatview instance is not attached anymore
// and he has to click the play button again (which is considered to be okay)
return
}
initMediaPlayer(message)
mediaPlayer?.let {
if (!it.isPlaying) {
it.start()
Log.d(TAG, "MediaPlayer has Started")
}
mediaPlayerHandler = Handler()
runOnUiThread(object : Runnable {
override fun run() {
if (mediaPlayer != null) {
if (message.isPlayingVoiceMessage) {
val pos = mediaPlayer!!.currentPosition / VOICE_MESSAGE_SEEKBAR_BASE
if (pos < (mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE)) {
lastRecordMediaPosition = mediaPlayer!!.currentPosition
message.voiceMessagePlayedSeconds = pos
message.voiceMessageSeekbarProgress = mediaPlayer!!.currentPosition
adapter?.update(message)
} else {
message.resetVoiceMessage = true
message.voiceMessagePlayedSeconds = 0
message.voiceMessageSeekbarProgress = 0
adapter?.update(message)
stopMediaPlayer(message)
}
}
}
mediaPlayerHandler.postDelayed(this, MILISEC_15)
}
})
message.isDownloadingVoiceMessage = false
message.isPlayingVoiceMessage = true
adapter?.update(message)
}
}
private fun pausePlayback(message: ChatMessage) {
if (mediaPlayer!!.isPlaying) {
mediaPlayer!!.pause()
Log.d(TAG, "MediaPlayer is paused")
}
message.isPlayingVoiceMessage = false
adapter?.update(message)
}
@Suppress("Detekt.TooGenericExceptionCaught")
private fun initMediaPlayer(message: ChatMessage) {
if (message != currentlyPlayedVoiceMessage) {
currentlyPlayedVoiceMessage?.let { stopMediaPlayer(it) }
}
if (mediaPlayer == null) {
val fileName = message.selectedIndividualHashMap!!["name"]
val absolutePath = context.cacheDir.absolutePath + "/" + fileName
try {
mediaPlayer = MediaPlayer().apply {
setDataSource(absolutePath)
prepare()
setOnPreparedListener {
currentlyPlayedVoiceMessage = message
message.voiceMessageDuration = mediaPlayer!!.duration / VOICE_MESSAGE_SEEKBAR_BASE
lastRecordedSeeked = false
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
setOnMediaTimeDiscontinuityListener { mp, _ ->
if (lastRecordMediaPosition > ONE_SECOND_IN_MILLIS && !lastRecordedSeeked) {
mp.seekTo(lastRecordMediaPosition)
lastRecordedSeeked = true
}
}
}
setOnCompletionListener {
stopMediaPlayer(message)
}
}
} catch (e: Exception) {
Log.e(TAG, "failed to initialize mediaPlayer", e)
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
}
}
private fun stopMediaPlayer(message: ChatMessage) {
message.isPlayingVoiceMessage = false
message.resetVoiceMessage = true
adapter?.update(message)
currentlyPlayedVoiceMessage = null
mediaPlayerHandler.removeCallbacksAndMessages(null)
try {
mediaPlayer?.let {
if (it.isPlaying) {
Log.d(TAG, "media player is stopped")
it.stop()
}
}
} catch (e: IllegalStateException) {
Log.e(TAG, "mediaPlayer was not initialized", e)
} finally {
mediaPlayer?.release()
mediaPlayer = null
}
}
override fun updateMediaPlayerProgressBySlider(messageWithSlidedProgress: ChatMessage, progress: Int) {
if (mediaPlayer != null) {
if (messageWithSlidedProgress == currentlyPlayedVoiceMessage) {
mediaPlayer!!.seekTo(progress)
}
}
}
@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 {
return 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 = CapabilitiesUtilNew.getAttachmentFolder(message.activeUser!!)
val fileName = message.selectedIndividualHashMap!!["name"]
var size = message.selectedIndividualHashMap!!["size"]
if (size == null) {
size = "-1"
}
val fileSize = size.toLong()
val fileId = message.selectedIndividualHashMap!!["id"]
val path = message.selectedIndividualHashMap!!["path"]
// check if download worker is already running
val workers = WorkManager.getInstance(
context
).getWorkInfosByTag(fileId!!)
try {
for (workInfo in workers.get()) {
if (workInfo.state == WorkInfo.State.RUNNING || workInfo.state == WorkInfo.State.ENQUEUED) {
Log.d(TAG, "Download worker for $fileId is already running or scheduled")
return
}
}
} catch (e: ExecutionException) {
Log.e(TAG, "Error when checking if worker already exists", e)
} catch (e: InterruptedException) {
Log.e(TAG, "Error when checking if worker already exists", e)
}
val data: Data = Data.Builder()
.putString(DownloadFileToCacheWorker.KEY_BASE_URL, baseUrl)
.putString(DownloadFileToCacheWorker.KEY_USER_ID, userId)
.putString(DownloadFileToCacheWorker.KEY_ATTACHMENT_FOLDER, attachmentFolder)
.putString(DownloadFileToCacheWorker.KEY_FILE_NAME, fileName)
.putString(DownloadFileToCacheWorker.KEY_FILE_PATH, path)
.putLong(DownloadFileToCacheWorker.KEY_FILE_SIZE, fileSize)
.build()
val downloadWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(DownloadFileToCacheWorker::class.java)
.setInputData(data)
.addTag(fileId)
.build()
WorkManager.getInstance().enqueue(downloadWorker)
WorkManager.getInstance(context).getWorkInfoByIdLiveData(downloadWorker.id)
.observeForever { workInfo: WorkInfo ->
if (workInfo.state == WorkInfo.State.SUCCEEDED) {
funToCallWhenDownloadSuccessful()
}
}
}
@SuppressLint("SimpleDateFormat")
private fun setVoiceRecordFileName() {
val simpleDateFormat = SimpleDateFormat(FILE_DATE_PATTERN)
val date: String = simpleDateFormat.format(Date())
val fileNameWithoutSuffix = String.format(
context.resources.getString(R.string.nc_voice_message_filename),
date,
currentConversation!!.displayName
)
val fileName = fileNameWithoutSuffix + VOICE_MESSAGE_FILE_SUFFIX
currentVoiceRecordFile = "${context.cacheDir.absolutePath}/$fileName"
}
private fun showRecordAudioUi(show: Boolean) {
if (show) {
binding.messageInputView.microphoneEnabledInfo.visibility = View.VISIBLE
binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.VISIBLE
binding.messageInputView.audioRecordDuration.visibility = View.VISIBLE
binding.messageInputView.slideToCancelDescription.visibility = View.VISIBLE
binding.messageInputView.attachmentButton.visibility = View.GONE
binding.messageInputView.smileyButton.visibility = View.GONE
binding.messageInputView.messageInput.visibility = View.GONE
binding.messageInputView.messageInput.hint = ""
binding.voiceRecordingLock.visibility = View.VISIBLE
} else {
binding.messageInputView.microphoneEnabledInfo.visibility = View.GONE
binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.GONE
binding.messageInputView.audioRecordDuration.visibility = View.GONE
binding.messageInputView.slideToCancelDescription.visibility = View.GONE
binding.messageInputView.attachmentButton.visibility = View.VISIBLE
binding.messageInputView.smileyButton.visibility = View.VISIBLE
binding.messageInputView.messageInput.visibility = View.VISIBLE
binding.messageInputView.messageInput.hint =
context.resources?.getString(R.string.nc_hint_enter_a_message)
binding.voiceRecordingLock.visibility = View.GONE
}
}
private fun startMicInputRecordingAnimation() {
val permissionCheck = ContextCompat.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
)
if (micInputAudioRecordThread == null && permissionCheck == PERMISSION_GRANTED) {
Log.d(TAG, "Mic Animation Started")
micInputAudioRecorder = AudioRecord(
MediaRecorder.AudioSource.MIC,
SAMPLE_RATE,
AudioFormat.CHANNEL_IN_MONO,
AudioFormat.ENCODING_PCM_16BIT,
bufferSize
)
isMicInputAudioThreadRunning = true
micInputAudioRecorder.startRecording()
initMicInputAudioRecordThread()
micInputAudioRecordThread!!.start()
binding.messageInputView.micInputCloud.startAnimators()
}
}
private fun initMicInputAudioRecordThread() {
micInputAudioRecordThread = Thread(
Runnable {
while (isMicInputAudioThreadRunning) {
val byteArr = ByteArray(bufferSize / 2)
micInputAudioRecorder.read(byteArr, 0, byteArr.size)
val d = abs(byteArr[0].toDouble())
if (d > AUDIO_VALUE_MAX) {
binding.messageInputView.micInputCloud.setRotationSpeed(
log10(d).toFloat(),
MicInputCloud.MAXIMUM_RADIUS
)
} else if (d > AUDIO_VALUE_MIN) {
binding.messageInputView.micInputCloud.setRotationSpeed(
log10(d).toFloat(),
MicInputCloud.EXTENDED_RADIUS
)
} else {
binding.messageInputView.micInputCloud.setRotationSpeed(
1f,
MicInputCloud.DEFAULT_RADIUS
)
}
Thread.sleep(AUDIO_VALUE_SLEEP)
}
}
)
}
private fun stopMicInputRecordingAnimation() {
if (micInputAudioRecordThread != null) {
Log.d(TAG, "Mic Animation Ended")
micInputAudioRecorder.stop()
micInputAudioRecorder.release()
isMicInputAudioThreadRunning = false
micInputAudioRecordThread = null
}
}
private fun isRecordAudioPermissionGranted(): Boolean {
return PermissionChecker.checkSelfPermission(
context,
Manifest.permission.RECORD_AUDIO
) == PERMISSION_GRANTED
}
private fun startAudioRecording(file: String) {
binding.messageInputView.audioRecordDuration.base = SystemClock.elapsedRealtime()
binding.messageInputView.audioRecordDuration.start()
val animation: Animation = AlphaAnimation(1.0f, 0.0f)
animation.duration = ANIMATION_DURATION
animation.interpolator = LinearInterpolator()
animation.repeatCount = Animation.INFINITE
animation.repeatMode = Animation.REVERSE
binding.messageInputView.microphoneEnabledInfo.startAnimation(animation)
initMediaRecorder(file)
VibrationUtils.vibrateShort(context)
}
private fun initMediaRecorder(file: String) {
recorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
mediaRecorderState = MediaRecorderState.INITIALIZED
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
mediaRecorderState = MediaRecorderState.CONFIGURED
setOutputFile(file)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioSamplingRate(VOICE_MESSAGE_SAMPLING_RATE)
setAudioEncodingBitRate(VOICE_MESSAGE_ENCODING_BIT_RATE)
setAudioChannels(VOICE_MESSAGE_CHANNELS)
try {
prepare()
mediaRecorderState = MediaRecorderState.PREPARED
} catch (e: IOException) {
mediaRecorderState = MediaRecorderState.ERROR
Log.e(TAG, "prepare for audio recording failed")
}
try {
start()
mediaRecorderState = MediaRecorderState.RECORDING
Log.d(TAG, "recording started")
} catch (e: IllegalStateException) {
mediaRecorderState = MediaRecorderState.ERROR
Log.e(TAG, "start for audio recording failed")
}
}
}
private fun stopAndSendAudioRecording() {
stopAudioRecording()
Log.d(TAG, "stopped and sent audio recording")
if (mediaRecorderState != MediaRecorderState.ERROR) {
val uri = Uri.fromFile(File(currentVoiceRecordFile))
uploadFile(uri.toString(), true)
} else {
mediaRecorderState = MediaRecorderState.INITIAL
}
}
private fun stopAndDiscardAudioRecording() {
stopAudioRecording()
Log.d(TAG, "stopped and discarded audio recording")
val cachedFile = File(currentVoiceRecordFile)
cachedFile.delete()
if (mediaRecorderState == MediaRecorderState.ERROR) {
mediaRecorderState = MediaRecorderState.INITIAL
}
}
@Suppress("Detekt.TooGenericExceptionCaught")
private fun stopAudioRecording() {
binding.messageInputView.audioRecordDuration.stop()
binding.messageInputView.microphoneEnabledInfo.clearAnimation()
recorder?.apply {
try {
if (mediaRecorderState == MediaRecorderState.RECORDING) {
stop()
reset()
mediaRecorderState = MediaRecorderState.INITIAL
Log.d(TAG, "stopped recorder")
}
release()
mediaRecorderState = MediaRecorderState.RELEASED
} catch (e: Exception) {
when (e) {
is java.lang.IllegalStateException,
is java.lang.RuntimeException -> {
mediaRecorderState = MediaRecorderState.ERROR
Log.e(TAG, "error while stopping recorder! with state $mediaRecorderState $e")
}
}
}
VibrationUtils.vibrateShort(context)
}
recorder = null
}
private fun requestRecordAudioPermissions() {
requestPermissions(
arrayOf(
Manifest.permission.RECORD_AUDIO
),
REQUEST_RECORD_AUDIO_PERMISSION
)
}
private fun requestCameraPermissions() {
requestPermissions(
arrayOf(
Manifest.permission.CAMERA
),
REQUEST_CAMERA_PERMISSION
)
}
private fun requestReadContacts() {
requestPermissions(
arrayOf(
Manifest.permission.READ_CONTACTS
),
REQUEST_READ_CONTACT_PERMISSION
)
}
private fun 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.messageInputView.visibility = View.GONE
} else {
binding.messageInputView.visibility = View.VISIBLE
}
}
private fun shouldShowLobby(): Boolean {
if (currentConversation != null) {
return CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "webinary-lobby") &&
currentConversation?.lobbyState == LobbyState.LOBBY_STATE_MODERATORS_ONLY &&
!ConversationUtils.canModerate(currentConversation!!, conversationUser!!) &&
!participantPermissions.canIgnoreLobby()
}
return false
}
private fun disableCallButtons() {
if (CapabilitiesUtilNew.isAbleToCall(conversationUser)) {
if (conversationVoiceCallMenuItem != null && conversationVideoMenuItem != null) {
conversationVoiceCallMenuItem?.icon?.alpha = SEMI_TRANSPARENT_INT
conversationVideoMenuItem?.icon?.alpha = SEMI_TRANSPARENT_INT
conversationVoiceCallMenuItem?.isEnabled = false
conversationVideoMenuItem?.isEnabled = false
} else {
Log.e(TAG, "call buttons were null when trying to disable them")
}
}
}
private fun enableCallButtons() {
if (CapabilitiesUtilNew.isAbleToCall(conversationUser)) {
if (conversationVoiceCallMenuItem != null && conversationVideoMenuItem != null) {
conversationVoiceCallMenuItem?.icon?.alpha = FULLY_OPAQUE_INT
conversationVideoMenuItem?.icon?.alpha = FULLY_OPAQUE_INT
conversationVoiceCallMenuItem?.isEnabled = true
conversationVideoMenuItem?.isEnabled = true
} else {
Log.e(TAG, "call buttons were null when trying to enable them")
}
}
}
private fun isReadOnlyConversation(): Boolean {
return currentConversation?.conversationReadOnlyState != null &&
currentConversation?.conversationReadOnlyState ==
ConversationReadOnlyState.CONVERSATION_READ_ONLY
}
private fun checkLobbyState() {
if (currentConversation != null &&
ConversationUtils.isLobbyViewApplicable(currentConversation!!, conversationUser!!)
) {
if (shouldShowLobby()) {
binding.lobby.lobbyView.visibility = View.VISIBLE
binding.messagesListView.visibility = View.GONE
binding.messageInputView.visibility = View.GONE
binding.progressBar.visibility = View.GONE
val sb = StringBuilder()
sb.append(resources!!.getText(R.string.nc_lobby_waiting))
.append("\n\n")
if (currentConversation?.lobbyTimer != null && currentConversation?.lobbyTimer !=
0L
) {
val 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()
} else {
binding.lobby.lobbyView.visibility = View.GONE
binding.messagesListView.visibility = View.VISIBLE
binding.messageInputView.inputEditText?.visibility = View.VISIBLE
if (isFirstMessagesProcessing && pastPreconditionFailed) {
pastPreconditionFailed = false
pullChatMessages(false)
} else if (futurePreconditionFailed) {
futurePreconditionFailed = false
pullChatMessages(true)
}
}
} else {
binding.lobby.lobbyView.visibility = View.GONE
binding.messagesListView.visibility = View.VISIBLE
if (!isVoiceRecordingLocked) {
binding.messageInputView.inputEditText?.visibility = View.VISIBLE
}
}
}
@Throws(IllegalStateException::class)
override fun onActivityResult(requestCode: Int, resultCode: Int, intent: Intent?) {
super.onActivityResult(requestCode, resultCode, intent)
if (resultCode != RESULT_OK && (requestCode != REQUEST_CODE_MESSAGE_SEARCH)) {
Log.e(TAG, "resultCode for received intent was != ok")
return
}
when (requestCode) {
REQUEST_CODE_SELECT_REMOTE_FILES -> {
val pathList = intent?.getStringArrayListExtra(RemoteFileBrowserActivity.EXTRA_SELECTED_PATHS)
if (pathList?.size!! >= 1) {
pathList
.chunked(CHUNK_SIZE)
.forEach { paths ->
val data = Data.Builder()
.putLong(KEY_INTERNAL_USER_ID, conversationUser!!.id!!)
.putString(KEY_ROOM_TOKEN, roomToken)
.putStringArray(KEY_FILE_PATHS, paths.toTypedArray())
.build()
val worker = OneTimeWorkRequest.Builder(ShareOperationWorker::class.java)
.setInputData(data)
.build()
WorkManager.getInstance().enqueue(worker)
}
}
}
REQUEST_CODE_CHOOSE_FILE -> {
try {
checkNotNull(intent)
filesToUpload.clear()
intent.clipData?.let {
for (index in 0 until it.itemCount) {
filesToUpload.add(it.getItemAt(index).uri.toString())
}
} ?: run {
checkNotNull(intent.data)
intent.data.let {
filesToUpload.add(intent.data.toString())
}
}
require(filesToUpload.isNotEmpty())
val filenamesWithLineBreaks = StringBuilder("\n")
for (file in filesToUpload) {
val filename = FileUtils.getFileName(Uri.parse(file), context)
filenamesWithLineBreaks.append(filename).append("\n")
}
val newFragment: DialogFragment = FileAttachmentPreviewFragment.newInstance(
filenamesWithLineBreaks.toString(),
filesToUpload,
this::uploadFiles
)
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)
}
}
REQUEST_CODE_SELECT_CONTACT -> {
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()
}
REQUEST_CODE_PICK_CAMERA -> {
if (resultCode == RESULT_OK) {
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(Uri.parse(file), context)
filenamesWithLineBreaks.append(filename).append("\n")
}
val newFragment: DialogFragment = FileAttachmentPreviewFragment.newInstance(
filenamesWithLineBreaks.toString(),
filesToUpload,
this::uploadFiles
)
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)
}
}
}
REQUEST_CODE_MESSAGE_SEARCH -> {
val messageId = intent?.getStringExtra(MessageSearchActivity.RESULT_KEY_MESSAGE_ID)
messageId?.let { id ->
scrollToMessageWithId(id)
}
}
}
}
private fun scrollToMessageWithId(messageId: String) {
val position = adapter?.items?.indexOfFirst {
it.item is ChatMessage && (it.item as ChatMessage).id == messageId
}
if (position != null && position >= 0) {
binding.messagesListView.smoothScrollToPosition(position)
} else {
// TODO show error that we don't have that message?
}
}
private fun writeContactToVcfFile(cursor: Cursor, file: File) {
val lookupKey = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.LOOKUP_KEY))
val uri = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_VCARD_URI, lookupKey)
val fd: AssetFileDescriptor = contentResolver!!.openAssetFileDescriptor(uri, "r")!!
fd.use {
val fis = fd.createInputStream()
file.createNewFile()
fis.use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
}
}
}
private fun hasGrantedPermissions(grantResults: IntArray): Boolean {
return permissionUtil.isFilesPermissionGranted()
}
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)
startActivityForResult(intent, REQUEST_CODE_SELECT_CONTACT)
} 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 = "") {
var metaData = ""
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\"}"
}
try {
require(fileUri.isNotEmpty())
UploadAndShareFilesWorker.upload(
fileUri,
roomToken,
currentConversation?.displayName!!,
metaData
)
} 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 showLocalFilePicker() {
val action = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
type = "*/*"
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true)
}
startActivityForResult(
Intent.createChooser(
action,
context.resources?.getString(
R.string.nc_upload_choose_local_files
)
),
REQUEST_CODE_CHOOSE_FILE
)
}
override fun startActivity(intent: Intent) {
val user = currentUserProvider.currentUser.blockingGet()
if (intent.data != null && TextUtils.equals(intent.action, Intent.ACTION_VIEW)) {
val uri = intent.data.toString()
if (uri.startsWith(user.baseUrl!!)) {
if (UriUtils.isInstanceInternalFileShareUrl(user.baseUrl!!, uri)) {
// https://cloud.nextcloud.com/f/41
val fileViewerUtils = FileViewerUtils(applicationContext, user)
fileViewerUtils.openFileInFilesApp(uri, UriUtils.extractInstanceInternalFileShareFileId(uri))
} else if (UriUtils.isInstanceInternalFileUrl(user.baseUrl!!, uri)) {
// https://cloud.nextcloud.com/apps/files/?dir=/Engineering&fileid=41
val fileViewerUtils = FileViewerUtils(applicationContext, user)
fileViewerUtils.openFileInFilesApp(uri, UriUtils.extractInstanceInternalFileFileId(uri))
} else if (UriUtils.isInstanceInternalFileUrlNew(user.baseUrl!!, uri)) {
// https://cloud.nextcloud.com/apps/files/?dir=/Engineering&fileid=41
val fileViewerUtils = FileViewerUtils(applicationContext, user)
fileViewerUtils.openFileInFilesApp(uri, UriUtils.extractInstanceInternalFileFileIdNew(uri))
} else {
super.startActivity(intent)
}
} else {
super.startActivity(intent)
}
} else {
super.startActivity(intent)
}
}
fun sendSelectLocalFileIntent() {
if (!permissionUtil.isFilesPermissionGranted()) {
requestReadFilesPermissions()
} else {
showLocalFilePicker()
}
}
fun sendChooseContactIntent() {
requestReadContacts()
}
fun showBrowserScreen() {
val sharingFileBrowserIntent = Intent(this, RemoteFileBrowserActivity::class.java)
startActivityForResult(sharingFileBrowserIntent, REQUEST_CODE_SELECT_REMOTE_FILES)
}
fun showShareLocationScreen() {
Log.d(TAG, "showShareLocationScreen")
val intent = Intent(this, LocationPickerActivity::class.java)
intent.putExtra(KEY_ROOM_TOKEN, roomToken)
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 setupMentionAutocomplete() {
val elevation = MENTION_AUTO_COMPLETE_ELEVATION
resources?.let {
val backgroundDrawable = ColorDrawable(it.getColor(R.color.bg_default, null))
val presenter = MentionAutocompletePresenter(this, roomToken)
val callback = MentionAutocompleteCallback(
this,
conversationUser!!,
binding.messageInputView.inputEditText,
viewThemeUtils
)
if (mentionAutocomplete == null && binding.messageInputView.inputEditText != null) {
mentionAutocomplete = Autocomplete.on<Mention>(binding.messageInputView.inputEditText)
.with(elevation)
.with(backgroundDrawable)
.with(MagicCharPolicy('@'))
.with(presenter)
.with(callback)
.build()
}
}
}
private fun validSessionId(): Boolean {
return currentConversation != null &&
sessionIdAfterRoomJoined?.isNotEmpty() == true &&
sessionIdAfterRoomJoined != "0"
}
private fun cancelReply() {
binding.messageInputView.findViewById<RelativeLayout>(R.id.quotedChatMessageView)?.visibility = View.GONE
binding.messageInputView.findViewById<ImageButton>(R.id.attachmentButton)?.visibility = View.VISIBLE
}
@Suppress("Detekt.TooGenericExceptionCaught")
private fun cancelNotificationsForCurrentConversation() {
if (conversationUser != null) {
if (!TextUtils.isEmpty(roomToken)) {
try {
NotificationUtils.cancelExistingNotificationsForRoom(
applicationContext,
conversationUser!!,
roomToken
)
} catch (e: RuntimeException) {
Log.w(TAG, "Cancel notifications for current conversation results with an error.", e)
}
}
}
}
override fun 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 {
return !isChangingConfigurations
}
private fun isNotInCall(): Boolean {
return !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) {
currentConversation?.displayName
error(e)
}
} else {
""
}
val statusMessageView = binding.chatToolbar.findViewById<TextView>(R.id.chat_toolbar_status_message)
if (currentConversation?.type == ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL) {
var statusMessage = ""
if (currentConversation?.statusIcon != null) {
statusMessage += currentConversation?.statusIcon
}
if (currentConversation?.statusMessage != null) {
statusMessage += currentConversation?.statusMessage
}
if (statusMessage.isNotEmpty()) {
viewThemeUtils.platform.colorTextView(statusMessageView, ColorRole.ON_SURFACE)
statusMessageView.text = statusMessage
statusMessageView.visibility = View.VISIBLE
} else {
statusMessageView.visibility = View.GONE
}
} 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)
}
currentlyPlayedVoiceMessage?.let { stopMediaPlayer(it) }
adapter = null
Log.d(TAG, "inConversation was set to false!")
}
private fun joinRoomWithPassword() {
// if ApplicationWideCurrentRoomHolder contains a session (because a call is active), then keep the sessionId
if (ApplicationWideCurrentRoomHolder.getInstance().currentRoomId ==
currentConversation!!.roomId
) {
sessionIdAfterRoomJoined = ApplicationWideCurrentRoomHolder.getInstance().session
ApplicationWideCurrentRoomHolder.getInstance().currentRoomId = roomId
ApplicationWideCurrentRoomHolder.getInstance().currentRoomToken = roomToken
ApplicationWideCurrentRoomHolder.getInstance().userInRoom = conversationUser
}
if (!validSessionId()) {
Log.d(TAG, "sessionID was not valid -> joinRoom")
var apiVersion = 1
// FIXME Fix API checking with guests?
if (conversationUser != null) {
apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.APIv4, 1))
}
val startNanoTime = System.nanoTime()
Log.d(TAG, "joinRoomWithPassword - joinRoom - calling: $startNanoTime")
chatViewModel.joinRoom(conversationUser!!, roomToken, roomPassword)
} else {
Log.d(TAG, "sessionID was valid -> skip joinRoom")
if (webSocketInstance != null) {
webSocketInstance?.joinRoomWithRoomTokenAndSession(
roomToken,
sessionIdAfterRoomJoined
)
}
if (isFirstMessagesProcessing) {
pullChatMessages(false)
} else {
pullChatMessages(true)
}
}
}
fun leaveRoom(funToCallWhenLeaveSuccessful: (() -> Unit)?) {
logConversationInfos("leaveRoom")
var apiVersion = 1
// FIXME Fix API checking with guests?
if (conversationUser != null) {
apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.APIv4, 1))
}
val startNanoTime = System.nanoTime()
Log.d(TAG, "leaveRoom - leaveRoom - calling: $startNanoTime")
ncApi.leaveRoom(
credentials,
ApiUtils.getUrlForParticipantsActive(
apiVersion,
conversationUser?.baseUrl,
roomToken
)
)
?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<GenericOverall> {
override fun onSubscribe(d: Disposable) {
disposables.add(d)
}
override fun onNext(genericOverall: GenericOverall) {
Log.d(TAG, "leaveRoom - leaveRoom - got response: $startNanoTime")
logConversationInfos("leaveRoom#onNext")
sendStopTypingMessage()
checkingLobbyStatus = false
if (getRoomInfoTimerHandler != null) {
getRoomInfoTimerHandler?.removeCallbacksAndMessages(null)
}
if (webSocketInstance != null && currentConversation != null) {
webSocketInstance?.joinRoomWithRoomTokenAndSession(
"",
sessionIdAfterRoomJoined
)
}
sessionIdAfterRoomJoined = "0"
if (funToCallWhenLeaveSuccessful != null) {
Log.d(TAG, "a callback action was set and is now executed because room was left successfully")
funToCallWhenLeaveSuccessful()
}
}
override fun onError(e: Throwable) {
Log.e(TAG, "leaveRoom - leaveRoom - ERROR", e)
}
override fun onComplete() {
Log.d(TAG, "leaveRoom - leaveRoom - completed: $startNanoTime")
disposables.dispose()
}
})
}
private fun submitMessage(sendWithoutNotification: Boolean) {
if (binding.messageInputView.inputEditText != null) {
val editable = binding.messageInputView.inputEditText!!.editableText
val mentionSpans = editable.getSpans(
0,
editable.length,
Spans.MentionChipSpan::class.java
)
var mentionSpan: Spans.MentionChipSpan
for (i in mentionSpans.indices) {
mentionSpan = mentionSpans[i]
var mentionId = mentionSpan.id
if (mentionId.contains(" ") ||
mentionId.startsWith("guest/") ||
mentionId.startsWith("group/")
) {
mentionId = "\"" + mentionId + "\""
}
editable.replace(editable.getSpanStart(mentionSpan), editable.getSpanEnd(mentionSpan), "@$mentionId")
}
binding.messageInputView.inputEditText?.setText("")
sendStopTypingMessage()
val replyMessageId: Int? = findViewById<RelativeLayout>(R.id.quotedChatMessageView)?.tag as Int?
sendMessage(
editable,
if (findViewById<RelativeLayout>(R.id.quotedChatMessageView)?.visibility == View.VISIBLE) {
replyMessageId
} else {
null
},
sendWithoutNotification
)
cancelReply()
}
}
private fun sendMessage(message: CharSequence, replyTo: Int?, sendWithoutNotification: Boolean) {
if (conversationUser != null) {
val apiVersion = ApiUtils.getChatApiVersion(conversationUser, intArrayOf(1))
ncApi.sendChatMessage(
credentials,
ApiUtils.getUrlForChat(apiVersion, conversationUser!!.baseUrl, roomToken),
message,
conversationUser!!.displayName,
replyTo,
sendWithoutNotification
)
?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<GenericOverall> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
@Suppress("Detekt.TooGenericExceptionCaught")
override fun onNext(genericOverall: GenericOverall) {
myFirstMessage = message
if (binding.popupBubbleView.isShown == true) {
binding.popupBubbleView.hide()
}
binding.messagesListView.smoothScrollToPosition(0)
}
override fun onError(e: Throwable) {
if (e is HttpException) {
val code = e.code()
if (code.toString().startsWith("2")) {
myFirstMessage = message
if (binding.popupBubbleView.isShown == true) {
binding.popupBubbleView.hide()
}
binding.messagesListView.smoothScrollToPosition(0)
}
}
}
override fun onComplete() {
// unused atm
}
})
}
showMicrophoneButton(true)
}
private fun setupWebsocket() {
if (conversationUser == null) {
return
}
webSocketInstance = WebSocketConnectionHelper.getWebSocketInstanceForUser(conversationUser!!)
if (webSocketInstance == null) {
Log.d(TAG, "webSocketInstance not set up. This should only happen when not using the HPB")
}
signalingMessageSender = webSocketInstance?.signalingMessageSender
}
fun pullChatMessages(lookIntoFuture: Boolean, setReadMarker: Boolean = true, xChatLastCommonRead: Int? = null) {
if (!validSessionId()) {
return
}
Log.d(TAG, "pullChatMessages. lookIntoFuture= $lookIntoFuture")
if (pullChatMessagesPending) {
// Sometimes pullChatMessages may be called before response to a previous call is received.
// In such cases just ignore the second call. Message processing will continue when response to the
// earlier call is received.
// More details: https://github.com/nextcloud/talk-android/pull/1766
Log.d(TAG, "pullChatMessages - pullChatMessagesPending is true, exiting")
return
}
pullChatMessagesPending = true
val pullChatMessagesFieldMap = setupFieldsForPullChatMessages(
lookIntoFuture,
xChatLastCommonRead,
setReadMarker
)
var apiVersion = 1
// FIXME this is a best guess, guests would need to get the capabilities themselves
if (conversationUser != null) {
apiVersion = ApiUtils.getChatApiVersion(conversationUser, intArrayOf(1))
}
ncApi.pullChatMessages(
credentials,
ApiUtils.getUrlForChat(apiVersion, conversationUser?.baseUrl, roomToken),
pullChatMessagesFieldMap
)
?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<Response<*>> {
override fun onSubscribe(d: Disposable) {
disposables.add(d)
}
@SuppressLint("NotifyDataSetChanged")
@Suppress("Detekt.TooGenericExceptionCaught")
override fun onNext(response: Response<*>) {
pullChatMessagesPending = false
when (response.code()) {
HTTP_CODE_NOT_MODIFIED -> {
Log.d(TAG, "pullChatMessages - HTTP_CODE_NOT_MODIFIED.")
if (lookIntoFuture) {
Log.d(TAG, "recursive call to pullChatMessages.")
pullChatMessages(true, setReadMarker, xChatLastCommonRead)
}
}
HTTP_CODE_PRECONDITION_FAILED -> {
Log.d(TAG, "pullChatMessages - HTTP_CODE_PRECONDITION_FAILED.")
if (lookIntoFuture) {
futurePreconditionFailed = true
} else {
pastPreconditionFailed = true
}
}
HTTP_CODE_OK -> {
Log.d(TAG, "pullChatMessages - HTTP_CODE_OK.")
val chatOverall = response.body() as ChatOverall?
var chatMessageList = chatOverall?.ocs!!.data!!
chatMessageList = handleSystemMessages(chatMessageList)
determinePreviousMessageIds(chatMessageList)
handleExpandableSystemMessages(chatMessageList)
processHeaderChatLastGiven(response, lookIntoFuture)
if (chatMessageList.isNotEmpty() &&
ChatMessage.SystemMessageType.CLEARED_CHAT == chatMessageList[0].systemMessageType
) {
adapter?.clear()
adapter?.notifyDataSetChanged()
}
if (lookIntoFuture) {
processMessagesFromTheFuture(chatMessageList)
} else {
processMessagesNotFromTheFuture(chatMessageList)
collapseSystemMessages()
}
val newXChatLastCommonRead = response.headers()["X-Chat-Last-Common-Read"]?.let {
Integer.parseInt(it)
}
processCallStartedMessages(chatMessageList)
updateReadStatusOfAllMessages(newXChatLastCommonRead)
adapter?.notifyDataSetChanged()
if (isFirstMessagesProcessing || lookIntoFuture) {
Log.d(TAG, "recursive call to pullChatMessages")
pullChatMessages(true, true, newXChatLastCommonRead)
}
}
}
processExpiredMessages()
if (isFirstMessagesProcessing) {
cancelNotificationsForCurrentConversation()
isFirstMessagesProcessing = false
binding.progressBar.visibility = View.GONE
binding.messagesListView.visibility = View.VISIBLE
collapseSystemMessages()
}
}
override fun onError(e: Throwable) {
Log.e(TAG, "pullChatMessages - pullChatMessages ERROR", e)
pullChatMessagesPending = false
}
override fun onComplete() {
pullChatMessagesPending = false
}
})
}
private fun processCallStartedMessages(chatMessageList: List<ChatMessage>) {
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,
chatMessageList
)
}
} catch (e: NoSuchElementException) {
Log.d(TAG, "No System messages found $e")
}
}
private fun setupFieldsForPullChatMessages(
lookIntoFuture: Boolean,
xChatLastCommonRead: Int?,
setReadMarker: Boolean
): HashMap<String, Int> {
val fieldMap = HashMap<String, Int>()
fieldMap["includeLastKnown"] = 0
if (!lookIntoFuture && isFirstMessagesProcessing) {
if (currentConversation != null) {
globalLastKnownFutureMessageId = currentConversation!!.lastReadMessage
globalLastKnownPastMessageId = currentConversation!!.lastReadMessage
fieldMap["includeLastKnown"] = 1
}
}
val lastKnown = if (lookIntoFuture) {
globalLastKnownFutureMessageId
} else {
globalLastKnownPastMessageId
}
fieldMap["lastKnownMessageId"] = lastKnown
xChatLastCommonRead?.let {
fieldMap["lastCommonReadId"] = it
}
val timeout = if (lookIntoFuture) {
LOOKING_INTO_FUTURE_TIMEOUT
} else {
0
}
fieldMap["timeout"] = timeout
fieldMap["limit"] = MESSAGE_PULL_LIMIT
if (lookIntoFuture) {
fieldMap["lookIntoFuture"] = 1
} else {
fieldMap["lookIntoFuture"] = 0
}
if (setReadMarker) {
fieldMap["setReadMarker"] = 1
} else {
fieldMap["setReadMarker"] = 0
}
return fieldMap
}
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 (CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "message-expiration")) {
deleteExpiredMessages()
}
}
private fun updateReadStatusOfAllMessages(xChatLastCommonRead: Int?) {
if (adapter != null) {
for (message in adapter!!.items) {
xChatLastCommonRead?.let {
updateReadStatusOfMessage(message, it)
}
}
}
}
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>) {
val shouldAddNewMessagesNotice = (adapter?.itemCount ?: 0) > 0 && chatMessageList.isNotEmpty()
if (shouldAddNewMessagesNotice) {
val unreadChatMessage = ChatMessage()
unreadChatMessage.jsonMessageId = -1
unreadChatMessage.actorId = "-1"
unreadChatMessage.timestamp = chatMessageList[0].timestamp
unreadChatMessage.message = context.getString(R.string.nc_new_messages)
adapter?.addToStart(unreadChatMessage, false)
}
addMessagesToAdapter(shouldAddNewMessagesNotice, chatMessageList)
if (shouldAddNewMessagesNotice && adapter != null) {
scrollToFirstUnreadMessage()
}
}
private fun processMessagesNotFromTheFuture(chatMessageList: List<ChatMessage>) {
var countGroupedMessages = 0
for (i in chatMessageList.indices) {
if (chatMessageList.size > i + 1) {
if (isSameDayNonSystemMessages(chatMessageList[i], chatMessageList[i + 1]) &&
chatMessageList[i + 1].actorId == chatMessageList[i].actorId &&
countGroupedMessages < GROUPED_MESSAGES_THRESHOLD
) {
chatMessageList[i].isGrouped = true
countGroupedMessages++
} else {
countGroupedMessages = 0
}
}
val chatMessage = chatMessageList[i]
chatMessage.isOneToOneConversation =
currentConversation?.type == ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
chatMessage.isFormerOneToOneConversation =
(currentConversation?.type == ConversationType.FORMER_ONE_TO_ONE)
chatMessage.activeUser = conversationUser
}
if (adapter != null) {
adapter?.addToEnd(chatMessageList, false)
}
scrollToRequestedMessageIfNeeded()
}
private fun scrollToFirstUnreadMessage() {
adapter?.let {
layoutManager?.scrollToPositionWithOffset(
it.getMessagePositionByIdInReverse("-1"),
binding.messagesListView.height / 2
)
}
}
private fun addMessagesToAdapter(shouldAddNewMessagesNotice: Boolean, chatMessageList: List<ChatMessage>) {
val isThereANewNotice =
shouldAddNewMessagesNotice || adapter?.getMessagePositionByIdInReverse("-1") != -1
for (chatMessage in chatMessageList) {
chatMessage.activeUser = conversationUser
val shouldScroll =
!isThereANewNotice &&
!shouldAddNewMessagesNotice &&
layoutManager?.findFirstVisibleItemPosition() == 0 ||
adapter != null &&
adapter?.itemCount == 0
modifyMessageCount(shouldAddNewMessagesNotice, shouldScroll)
adapter?.let {
chatMessage.isGrouped = (
it.isPreviousSameAuthor(chatMessage.actorId, -1) &&
it.getSameAuthorLastMessagesCount(chatMessage.actorId) %
GROUPED_MESSAGES_SAME_AUTHOR_THRESHOLD > 0
)
chatMessage.isOneToOneConversation =
(currentConversation?.type == ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL)
chatMessage.isFormerOneToOneConversation =
(currentConversation?.type == ConversationType.FORMER_ONE_TO_ONE)
it.addToStart(chatMessage, shouldScroll)
}
}
}
private fun modifyMessageCount(shouldAddNewMessagesNotice: Boolean, shouldScroll: Boolean) {
if (!shouldAddNewMessagesNotice && !shouldScroll) {
binding.popupBubbleView.isShown.let {
if (it) {
newMessagesCount++
} else {
newMessagesCount = 1
binding.scrollDownButton.visibility = View.GONE
binding.popupBubbleView.show()
}
}
} else {
binding.scrollDownButton.visibility = View.GONE
newMessagesCount = 0
}
}
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 processHeaderChatLastGiven(response: Response<*>, isFromTheFuture: Boolean) {
val xChatLastGivenHeader: String? = response.headers()["X-Chat-Last-Given"]
val header = if (response.headers().size > 0 &&
xChatLastGivenHeader?.isNotEmpty() == true
) {
xChatLastGivenHeader.toInt()
} else {
return
}
if (header > 0) {
if (isFromTheFuture) {
globalLastKnownFutureMessageId = header
} else {
if (globalLastKnownFutureMessageId == -1) {
globalLastKnownFutureMessageId = header
}
globalLastKnownPastMessageId = header
}
}
}
private fun scrollToRequestedMessageIfNeeded() {
intent.getStringExtra(BundleKeys.KEY_MESSAGE_ID)?.let {
scrollToMessageWithId(it)
}
}
private fun isSameDayNonSystemMessages(messageLeft: ChatMessage, messageRight: ChatMessage): Boolean {
return TextUtils.isEmpty(messageLeft.systemMessage) &&
TextUtils.isEmpty(messageRight.systemMessage) &&
DateFormatter.isSameDay(messageLeft.createdAt, messageRight.createdAt)
}
override fun onLoadMore(page: Int, totalItemsCount: Int) {
pullChatMessages(false)
}
override fun format(date: Date): String {
return if (DateFormatter.isToday(date)) {
resources!!.getString(R.string.nc_date_header_today)
} else if (DateFormatter.isYesterday(date)) {
resources!!.getString(R.string.nc_date_header_yesterday)
} else {
DateFormatter.format(date, DateFormatter.Template.STRING_DAY_MONTH_YEAR)
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
super.onCreateOptionsMenu(menu)
menuInflater.inflate(R.menu.menu_conversation, menu)
binding.messageInputView.context?.let {
viewThemeUtils.platform.colorToolbarMenuIcon(
it,
menu.findItem(R.id.conversation_voice_call)
)
viewThemeUtils.platform.colorToolbarMenuIcon(
it,
menu.findItem(R.id.conversation_video_call)
)
}
if (conversationUser?.userId == "?") {
menu.removeItem(R.id.conversation_info)
} else {
conversationInfoMenuItem = menu.findItem(R.id.conversation_info)
if (CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "rich-object-list-media")) {
conversationSharedItemsItem = menu.findItem(R.id.shared_items)
} else {
menu.removeItem(R.id.shared_items)
}
loadAvatarForStatusBar()
setActionBarTitle()
}
if (CapabilitiesUtilNew.isAbleToCall(conversationUser)) {
conversationVoiceCallMenuItem = menu.findItem(R.id.conversation_voice_call)
conversationVideoMenuItem = menu.findItem(R.id.conversation_video_call)
if (CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "silent-call")) {
Handler().post {
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 onPrepareOptionsMenu(menu: Menu): Boolean {
super.onPrepareOptionsMenu(menu)
conversationUser?.let {
if (CapabilitiesUtilNew.hasSpreedFeatureCapability(it, "read-only-rooms")) {
checkShowCallButtons()
}
val searchItem = menu.findItem(R.id.conversation_search)
searchItem.isVisible = CapabilitiesUtilNew.isUnifiedSearchAvailable(it)
}
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return 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
}
else -> super.onOptionsItemSelected(item)
}
}
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)
startActivityForResult(intent, REQUEST_CODE_MESSAGE_SEARCH)
}
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()
// setDeletionFlagsAndRemoveInfomessages
if (isInfoMessageAboutDeletion(currentMessage)) {
if (!chatMessageMap.containsKey(currentMessage.value.parentMessage!!.id)) {
// if chatMessageMap doesn't contain message to delete (this happens when lookingIntoFuture),
// the message to delete has to be modified directly inside the adapter
setMessageAsDeleted(currentMessage.value.parentMessage)
} else {
chatMessageMap[currentMessage.value.parentMessage!!.id]!!.isDeleted = true
}
chatMessageIterator.remove()
} else if (isReactionsMessage(currentMessage)) {
// delete reactions system messages
if (!chatMessageMap.containsKey(currentMessage.value.parentMessage!!.id)) {
updateAdapterForReaction(currentMessage.value.parentMessage)
}
chatMessageIterator.remove()
} else if (isPollVotedMessage(currentMessage)) {
// delete poll system messages
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 {
return currentMessage.value.parentMessage != null && currentMessage.value.systemMessageType == ChatMessage
.SystemMessageType.MESSAGE_DELETED
}
private fun isReactionsMessage(currentMessage: MutableMap.MutableEntry<String, ChatMessage>): Boolean {
return currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION ||
currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION_DELETED ||
currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.REACTION_REVOKED
}
private fun isPollVotedMessage(currentMessage: MutableMap.MutableEntry<String, ChatMessage>): Boolean {
return currentMessage.value.systemMessageType == ChatMessage.SystemMessageType.POLL_VOTED
}
private fun startACall(isVoiceOnlyCall: Boolean, callWithoutNotification: Boolean) {
currentConversation?.let {
if (conversationUser != null) {
val pp = ParticipantPermissions(conversationUser!!, 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(KEY_ROOM_ID, roomId)
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_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 == 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) {
reactionsRepository.deleteReaction(roomToken, chatMessage, emoji)
.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(ReactionDeletedObserver())
} else {
reactionsRepository.addReaction(roomToken, chatMessage, emoji)
.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(ReactionAddedObserver())
}
}
override fun onLongClickReactions(chatMessage: ChatMessage) {
ShowReactionsDialog(
this,
roomToken,
chatMessage,
conversationUser,
participantPermissions.hasChatPermission(),
ncApi
).show()
}
inner class ReactionAddedObserver : Observer<ReactionAddedModel> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(reactionAddedModel: ReactionAddedModel) {
Log.d(TAG, "onNext")
if (reactionAddedModel.success) {
updateUiToAddReaction(
reactionAddedModel.chatMessage,
reactionAddedModel.emoji
)
}
}
override fun onError(e: Throwable) {
Log.d(TAG, "onError")
}
override fun onComplete() {
Log.d(TAG, "onComplete")
}
}
inner class ReactionDeletedObserver : Observer<ReactionDeletedModel> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(reactionDeletedModel: ReactionDeletedModel) {
Log.d(TAG, "onNext")
if (reactionDeletedModel.success) {
updateUiToDeleteReaction(
reactionDeletedModel.chatMessage,
reactionDeletedModel.emoji
)
}
}
override fun onError(e: Throwable) {
Log.d(TAG, "onError")
}
override fun onComplete() {
Log.d(TAG, "onComplete")
}
}
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 (hasVisibleItems(message) && !isSystemMessage(message)) {
MessageActionsDialog(
this,
message,
conversationUser,
currentConversation,
isShowMessageDeletionButton(message),
participantPermissions.hasChatPermission()
).show()
}
}
private fun isSystemMessage(message: ChatMessage): Boolean {
return 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(conversationUser, intArrayOf(1))
}
ncApi.deleteChatMessage(
credentials,
ApiUtils.getUrlForChatMessage(
apiVersion,
conversationUser?.baseUrl,
roomToken,
message?.id
)
)?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<ChatOverallSingleMessage> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(t: ChatOverallSingleMessage) {
if (t.ocs!!.meta!!.statusCode == HttpURLConnection.HTTP_ACCEPTED) {
Snackbar.make(
binding.root,
R.string.nc_delete_message_leaked_to_matterbridge,
Snackbar.LENGTH_LONG
).show()
}
}
override fun onError(e: Throwable) {
Log.e(
TAG,
"Something went wrong when trying to delete message with id " +
message?.id,
e
)
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
override fun onComplete() {
// unused atm
}
})
}
}
fun replyPrivately(message: IMessage?) {
val apiVersion =
ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.APIv4, 1))
val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom(
apiVersion,
conversationUser?.baseUrl,
"1",
null,
message?.user?.id?.substring(INVITE_LENGTH),
null
)
ncApi.createRoom(
credentials,
retrofitBucket.url,
retrofitBucket.queryMap
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : Observer<RoomOverall> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(roomOverall: RoomOverall) {
val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token)
bundle.putString(KEY_ROOM_ID, roomOverall.ocs!!.data!!.roomId)
leaveRoom {
val chatIntent = Intent(context, ChatActivity::class.java)
chatIntent.putExtras(bundle)
chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(chatIntent)
}
}
override fun onError(e: Throwable) {
Log.e(TAG, e.message, e)
}
override fun onComplete() {
// unused atm
}
})
}
fun forwardMessage(message: IMessage?) {
val bundle = Bundle()
bundle.putBoolean(BundleKeys.KEY_FORWARD_MSG_FLAG, true)
bundle.putString(BundleKeys.KEY_FORWARD_MSG_TEXT, message?.text)
bundle.putString(BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM, roomId)
val intent = Intent(this, ConversationsListActivity::class.java)
intent.putExtras(bundle)
startActivity(intent)
}
fun remindMeLater(message: ChatMessage?) {
Log.d(TAG, "remindMeLater called")
val newFragment: DialogFragment = DateTimePickerFragment.newInstance(
roomToken,
message!!.id,
chatViewModel
)
newFragment.show(supportFragmentManager, DateTimePickerFragment.TAG)
}
fun markAsUnread(message: IMessage?) {
val chatMessage = message as ChatMessage?
if (chatMessage!!.previousMessageId > NO_PREVIOUS_MESSAGE_ID) {
ncApi.setChatReadMarker(
credentials,
ApiUtils.getUrlForChatReadMarker(
ApiUtils.getChatApiVersion(conversationUser, intArrayOf(ApiUtils.APIv1)),
conversationUser?.baseUrl,
roomToken
),
chatMessage.previousMessageId
)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(object : Observer<GenericOverall> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(t: GenericOverall) {
// unused atm
}
override fun onError(e: Throwable) {
Log.e(TAG, e.message, e)
}
override fun onComplete() {
// unused atm
}
})
}
}
fun copyMessage(message: IMessage?) {
val clipboardManager =
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 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 {
return !message.isDeleted || // copy message
message.replyable || // reply to
message.replyable && // reply privately
conversationUser?.userId?.isNotEmpty() == true && conversationUser!!.userId != "?" &&
message.user.id.startsWith("users/") &&
message.user.id.substring(ACTOR_LENGTH) != currentConversation?.actorId &&
currentConversation?.type != ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL ||
isShowMessageDeletionButton(message) || // delete
ChatMessage.MessageType.REGULAR_TEXT_MESSAGE == message.getCalculateMessageType() || // forward
message.previousMessageId > NO_PREVIOUS_MESSAGE_ID && // mark as unread
ChatMessage.MessageType.SYSTEM_MESSAGE != message.getCalculateMessageType() &&
BuildConfig.DEBUG
}
fun replyToMessage(message: IMessage?) {
val chatMessage = message as ChatMessage?
chatMessage?.let {
binding.messageInputView.findViewById<ImageButton>(R.id.attachmentButton)?.visibility =
View.GONE
binding.messageInputView.findViewById<ImageButton>(R.id.cancelReplyButton)?.visibility =
View.VISIBLE
val quotedMessage = binding.messageInputView.findViewById<EmojiTextView>(R.id.quotedMessage)
quotedMessage?.maxLines = 2
quotedMessage?.ellipsize = TextUtils.TruncateAt.END
quotedMessage?.text = it.text
binding.messageInputView.findViewById<EmojiTextView>(R.id.quotedMessageAuthor)?.text =
it.actorDisplayName ?: context.getText(R.string.nc_nick_guest)
conversationUser?.let {
val quotedMessageImage = binding.messageInputView.findViewById<ImageView>(R.id.quotedMessageImage)
chatMessage.imageUrl?.let { previewImageUrl ->
quotedMessageImage?.visibility = View.VISIBLE
val px = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
QUOTED_MESSAGE_IMAGE_MAX_HEIGHT,
resources?.displayMetrics
)
quotedMessageImage?.maxHeight = px.toInt()
val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams
layoutParams.flexGrow = 0f
quotedMessageImage.layoutParams = layoutParams
quotedMessageImage.load(previewImageUrl) {
addHeader("Authorization", credentials!!)
}
} ?: run {
binding.messageInputView.findViewById<ImageView>(R.id.quotedMessageImage)?.visibility = View.GONE
}
}
val quotedChatMessageView =
binding.messageInputView.findViewById<RelativeLayout>(R.id.quotedChatMessageView)
quotedChatMessageView?.tag = message?.jsonMessageId
quotedChatMessageView?.visibility = View.VISIBLE
}
}
private fun showMicrophoneButton(show: Boolean) {
if (show && CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "voice-message-sharing")) {
Log.d(TAG, "Microphone shown")
binding.messageInputView.messageSendButton.visibility = View.GONE
binding.messageInputView.recordAudioButton.visibility = View.VISIBLE
} else {
Log.d(TAG, "Microphone hidden")
binding.messageInputView.messageSendButton.visibility = View.VISIBLE
binding.messageInputView.recordAudioButton.visibility = View.GONE
}
}
private fun setMessageAsDeleted(message: IMessage?) {
val messageTemp = message as ChatMessage
messageTemp.isDeleted = true
messageTemp.isOneToOneConversation =
currentConversation?.type == ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL
messageTemp.activeUser = conversationUser
adapter?.update(messageTemp)
}
private fun updateAdapterForReaction(message: IMessage?) {
val messageTemp = message as ChatMessage
messageTemp.isOneToOneConversation =
currentConversation?.type == 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
message.reactionsSelf!!.remove(emoji)
adapter?.update(message)
}
private fun isShowMessageDeletionButton(message: ChatMessage): Boolean {
if (conversationUser == null) return false
val isUserAllowedByPrivileges = if (message.actorId == conversationUser!!.userId) {
true
} else {
ConversationUtils.canModerate(currentConversation!!, conversationUser!!)
}
val isOlderThanSixHours = message
.createdAt
.before(Date(System.currentTimeMillis() - AGE_THRESHOLD_FOR_DELETE_MESSAGE))
return when {
!isUserAllowedByPrivileges -> false
isOlderThanSixHours -> false
message.systemMessageType != ChatMessage.SystemMessageType.DUMMY -> false
message.isDeleted -> false
!CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "delete-messages") -> false
!participantPermissions.hasChatPermission() -> false
else -> true
}
}
override fun hasContentFor(message: ChatMessage, type: Byte): Boolean {
return when (type) {
CONTENT_TYPE_LOCATION -> message.hasGeoLocation()
CONTENT_TYPE_VOICE_MESSAGE -> message.isVoiceMessage
CONTENT_TYPE_POLL -> message.isPoll()
CONTENT_TYPE_LINK_PREVIEW -> message.isLinkPreview()
CONTENT_TYPE_SYSTEM_MESSAGE -> !TextUtils.isEmpty(message.systemMessage)
CONTENT_TYPE_UNREAD_NOTICE_MESSAGE -> message.id == "-1"
CONTENT_TYPE_CALL_STARTED -> message.id == "-2"
else -> false
}
}
private fun processMostRecentMessage(recent: ChatMessage, chatMessageList: List<ChatMessage>) {
when (recent.systemMessageType) {
ChatMessage.SystemMessageType.CALL_STARTED -> { // add CallStartedMessage with id -2
if (!callStarted) {
val callStartedChatMessage = ChatMessage()
callStartedChatMessage.jsonMessageId = CALL_STARTED_ID
callStartedChatMessage.actorId = "-2"
val name = if (recent.actorDisplayName.isNullOrEmpty()) "Guest" else recent.actorDisplayName
callStartedChatMessage.actorDisplayName = name
callStartedChatMessage.actorType = recent.actorType
callStartedChatMessage.timestamp = chatMessageList[0].timestamp
callStartedChatMessage.message = null
adapter?.addToStart(callStartedChatMessage, false)
callStarted = true
}
} // remove CallStartedMessage with id -2
ChatMessage.SystemMessageType.CALL_ENDED,
ChatMessage.SystemMessageType.CALL_MISSED,
ChatMessage.SystemMessageType.CALL_TRIED,
ChatMessage.SystemMessageType.CALL_ENDED_EVERYONE -> {
adapter?.deleteById("-2")
callStarted = false
} // remove message of id -2
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 != ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL ||
currentConversation?.name != userMentionClickEvent.userId
) {
var apiVersion = 1
// FIXME Fix API checking with guests?
if (conversationUser != null) {
apiVersion = ApiUtils.getConversationApiVersion(conversationUser, intArrayOf(ApiUtils.APIv4, 1))
}
val retrofitBucket = ApiUtils.getRetrofitBucketForCreateRoom(
apiVersion,
conversationUser?.baseUrl,
"1",
null,
userMentionClickEvent.userId,
null
)
ncApi.createRoom(
credentials,
retrofitBucket.url,
retrofitBucket.queryMap
)
?.subscribeOn(Schedulers.io())
?.observeOn(AndroidSchedulers.mainThread())
?.subscribe(object : Observer<RoomOverall> {
override fun onSubscribe(d: Disposable) {
// unused atm
}
override fun onNext(roomOverall: RoomOverall) {
val bundle = Bundle()
bundle.putString(KEY_ROOM_TOKEN, roomOverall.ocs!!.data!!.token)
bundle.putString(KEY_ROOM_ID, roomOverall.ocs!!.data!!.roomId)
leaveRoom {
val chatIntent = Intent(context, ChatActivity::class.java)
chatIntent.putExtras(bundle)
chatIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
startActivity(chatIntent)
}
}
override fun onError(e: Throwable) {
Log.e(TAG, "error after clicking on user mention chip", e)
Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show()
}
override fun onComplete() {
// unused atm
}
})
}
}
fun sendPictureFromCamIntent() {
if (!permissionUtil.isCameraPermissionGranted()) {
requestCameraPermissions()
} else {
startActivityForResult(TakePhotoActivity.createIntent(context), REQUEST_CODE_PICK_CAMERA)
}
}
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)
startActivityForResult(takeVideoIntent, REQUEST_CODE_PICK_CAMERA)
}
}
}
}
}
fun createPoll() {
val pollVoteDialog = PollCreateDialogFragment.newInstance(
roomToken
)
pollVoteDialog.show(supportFragmentManager, TAG)
}
fun jumpToQuotedMessage(parentMessage: ChatMessage) {
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)
break
}
}
}
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 editMessage(message: ChatMessage) {
editableBehaviorSubject.onNext(true)
editMessage = message
initMessageInputView()
}
companion object {
private 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 NEW_MESSAGES_POPUP_BUBBLE_DELAY: Long = 200
private const val GET_ROOM_INFO_DELAY_NORMAL: Long = 30000
private const val GET_ROOM_INFO_DELAY_LOBBY: Long = 5000
private const val HTTP_CODE_OK: Int = 200
private const val AGE_THRESHOLD_FOR_DELETE_MESSAGE: Int = 21600000 // (6 hours in millis = 6 * 3600 * 1000)
private const val REQUEST_CODE_CHOOSE_FILE: Int = 555
private const val REQUEST_CODE_SELECT_CONTACT: Int = 666
private const val REQUEST_CODE_MESSAGE_SEARCH: Int = 777
private const val REQUEST_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 REQUEST_CODE_PICK_CAMERA: Int = 333
private const val REQUEST_CODE_SELECT_REMOTE_FILES = 888
private const val OBJECT_MESSAGE: String = "{object}"
private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000
private const val MINIMUM_VOICE_RECORD_TO_STOP: Int = 200
private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -50
private const val VOICE_RECORD_LOCK_BUTTON_Y: Int = -130
private const val VOICE_MESSAGE_META_DATA = "{\"messageType\":\"voice-message\"}"
private const val VOICE_MESSAGE_FILE_SUFFIX = ".mp3"
// Samplingrate 22050 was chosen because somehow 44100 failed to playback on safari when recorded on android.
// Please test with firefox, chrome, safari and mobile clients if changing anything regarding the sound.
private const val VOICE_MESSAGE_SAMPLING_RATE = 22050
private const val VOICE_MESSAGE_ENCODING_BIT_RATE = 32000
private const val VOICE_MESSAGE_CHANNELS = 1
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 GROUPED_MESSAGES_THRESHOLD = 4
private const val GROUPED_MESSAGES_SAME_AUTHOR_THRESHOLD = 5
private const val TOOLBAR_AVATAR_RATIO = 1.5
private const val STATUS_SIZE_IN_DP = 9f
private const val HTTP_CODE_NOT_MODIFIED = 304
private const val HTTP_CODE_PRECONDITION_FAILED = 412
private const val QUOTED_MESSAGE_IMAGE_MAX_HEIGHT = 96f
private const val MENTION_AUTO_COMPLETE_ELEVATION = 6f
private const val MESSAGE_PULL_LIMIT = 100
private const val INVITE_LENGTH = 6
private const val ACTOR_LENGTH = 6
private const val ANIMATION_DURATION: Long = 750
private const val LOOKING_INTO_FUTURE_TIMEOUT = 30
private const val CHUNK_SIZE: Int = 10
private const val ONE_SECOND_IN_MILLIS = 1000
private const val SAMPLE_RATE = 8000
private const val VOICE_RECORDING_LOCK_ANIMATION_DURATION = 500
private const val AUDIO_VALUE_MAX = 40
private const val AUDIO_VALUE_MIN = 20
private const val AUDIO_VALUE_SLEEP: Long = 50
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_DURATION_TO_SEND_NEXT_TYPING_MESSAGE = 10000L
private const val TYPING_INTERVAL_TO_SEND_NEXT_TYPING_MESSAGE = 1000L
private const val TYPING_STARTED_SIGNALING_MESSAGE_TYPE = "startedTyping"
private const val TYPING_STOPPED_SIGNALING_MESSAGE_TYPE = "stoppedTyping"
private const val CALL_STARTED_ID = -2
private const val MILISEC_15: Long = 15
private const val LINEBREAK = "\n"
private const val CURSOR_KEY = "_cursor"
}
}