mirror of
https://github.com/nextcloud/talk-android
synced 2025-06-19 03:29:28 +01:00
4703 lines
187 KiB
Kotlin
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"
|
|
}
|
|
}
|