diff --git a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt index 7d9f24c5f3..95a6ce2544 100644 --- a/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt +++ b/app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt @@ -37,6 +37,7 @@ import com.nextcloud.talk.components.filebrowser.webdav.DavUtils import com.nextcloud.talk.dagger.modules.BusModule import com.nextcloud.talk.dagger.modules.ContextModule import com.nextcloud.talk.dagger.modules.DatabaseModule +import com.nextcloud.talk.dagger.modules.ManagerModule import com.nextcloud.talk.dagger.modules.RepositoryModule import com.nextcloud.talk.dagger.modules.RestModule import com.nextcloud.talk.dagger.modules.UtilsModule @@ -77,7 +78,8 @@ import javax.inject.Singleton ViewModelModule::class, RepositoryModule::class, UtilsModule::class, - ThemeModule::class + ThemeModule::class, + ManagerModule::class ] ) @Singleton diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 4fdb36f59b..0236e0e29c 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -14,79 +14,50 @@ package com.nextcloud.talk.chat import android.Manifest -import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.app.Activity -import android.content.BroadcastReceiver import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent -import android.content.IntentFilter 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.AudioFocusRequest -import android.media.AudioFormat -import android.media.AudioManager -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.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.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts 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.fragment.app.commit import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager @@ -97,13 +68,10 @@ 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 @@ -136,8 +104,8 @@ 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.chat.viewmodels.MessageInputViewModel import com.nextcloud.talk.conversationinfo.ConversationInfoActivity import com.nextcloud.talk.conversationlist.ConversationsListActivity import com.nextcloud.talk.data.user.model.User @@ -159,19 +127,14 @@ import com.nextcloud.talk.models.json.capabilities.SpreedCapability import com.nextcloud.talk.models.json.chat.ChatMessage import com.nextcloud.talk.models.json.chat.ChatOverall import com.nextcloud.talk.models.json.chat.ReadStatus -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.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 @@ -182,7 +145,6 @@ import com.nextcloud.talk.ui.recyclerview.MessageSwipeCallback import com.nextcloud.talk.utils.ApiUtils import com.nextcloud.talk.utils.AudioUtils import com.nextcloud.talk.utils.CapabilitiesUtil -import com.nextcloud.talk.utils.CharPolicy import com.nextcloud.talk.utils.ContactUtils import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.utils.DateConstants @@ -190,7 +152,6 @@ 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.Mimetype import com.nextcloud.talk.utils.NotificationUtils import com.nextcloud.talk.utils.ParticipantPermissions @@ -211,7 +172,6 @@ import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_SWITCH_TO_ROOM import com.nextcloud.talk.utils.permissions.PlatformPermissionUtil import com.nextcloud.talk.utils.rx.DisposableSet import com.nextcloud.talk.utils.singletons.ApplicationWideCurrentRoomHolder -import com.nextcloud.talk.utils.text.Spans import com.nextcloud.talk.webrtc.WebSocketConnectionHelper import com.nextcloud.talk.webrtc.WebSocketInstance import com.otaliastudios.autocomplete.Autocomplete @@ -221,8 +181,6 @@ 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.subjects.BehaviorSubject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -237,12 +195,9 @@ 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) @@ -275,8 +230,7 @@ class ChatActivity : lateinit var viewModelFactory: ViewModelProvider.Factory lateinit var chatViewModel: ChatViewModel - - private lateinit var editMessage: ChatMessage + lateinit var messageInputViewModel: MessageInputViewModel private val startSelectContactForResult = registerForActivityResult( ActivityResultContracts @@ -334,7 +288,7 @@ class ChatActivity : private var globalLastKnownFutureMessageId = -1 private var globalLastKnownPastMessageId = -1 var adapter: TalkMessagesListAdapter? = null - private var mentionAutocomplete: Autocomplete<*>? = null + var mentionAutocomplete: Autocomplete<*>? = null var layoutManager: LinearLayoutManager? = null var pullChatMessagesPending = false var newMessagesCount = 0 @@ -343,86 +297,32 @@ class ChatActivity : 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 webSocketInstance: WebSocketInstance? = null + var signalingMessageSender: SignalingMessageSender? = null var getRoomInfoTimerHandler: Handler? = null - var pastPreconditionFailed = false - var futurePreconditionFailed = false private val filesToUpload: MutableList = 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 val editableBehaviorSubject = BehaviorSubject.createDefault(false) - private val editedTextBehaviorSubject = BehaviorSubject.createDefault("") - - private var mediaRecorderState: MediaRecorderState = MediaRecorderState.INITIAL - - private var voicePreviewMediaPlayer: MediaPlayer? = null - private var voicePreviewObjectAnimator: ObjectAnimator? = null + lateinit var sharedText: String var mediaPlayer: MediaPlayer? = null var mediaPlayerHandler: Handler? = null 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 val audioFocusChangeListener = getAudioFocusChangeListener() - - private val noisyAudioStreamReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context?, intent: Intent?) { - chatViewModel.isPausedDueToBecomingNoisy = true - if (isVoicePreviewPlaying) { - pausePreviewVoicePlaying() - } - if (currentlyPlayedVoiceMessage != null) { - pausePlayback(currentlyPlayedVoiceMessage!!) - } - } - } - - private lateinit var participantPermissions: ParticipantPermissions + lateinit var participantPermissions: ParticipantPermissions private var videoURI: Uri? = null @@ -437,8 +337,6 @@ class ChatActivity : } } - var typingTimer: CountDownTimer? = null - var typedWhileTypingTimerIsRunning: Boolean = false val typingParticipants = HashMap() var callStarted = false @@ -505,13 +403,12 @@ class ChatActivity : setContentView(binding.root) setupSystemColors() - binding.messageInputView.messageSendButton.visibility = View.GONE - conversationUser = currentUserProvider.currentUser.blockingGet() handleIntent(intent) chatViewModel = ViewModelProvider(this, viewModelFactory)[ChatViewModel::class.java] + messageInputViewModel = ViewModelProvider(this, viewModelFactory)[MessageInputViewModel::class.java] binding.progressBar.visibility = View.VISIBLE @@ -593,14 +490,8 @@ class ChatActivity : 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) - this.lifecycle.addObserver(ChatViewModel.LifeCycleObserver) + this.lifecycle.addObserver(chatViewModel) } override fun onSaveInstanceState(outState: Bundle) { @@ -621,25 +512,8 @@ class ChatActivity : override fun onStop() { super.onStop() active = false - stopPreviewVoicePlaying() - if (isMicInputAudioThreadRunning) { - stopMicInputRecordingAnimation() - } - if (mediaRecorderState == MediaRecorderState.RECORDING) { - stopAudioRecording() - } - 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) - this.lifecycle.removeObserver(ChatViewModel.LifeCycleObserver) + this.lifecycle.removeObserver(chatViewModel) } @SuppressLint("NotifyDataSetChanged") @@ -664,57 +538,46 @@ class ChatActivity : chatViewModel.getCapabilitiesViewState.observe(this) { state -> when (state) { - is ChatViewModel.GetCapabilitiesSuccessState -> { + is ChatViewModel.GetCapabilitiesUpdateState -> { spreedCapabilities = state.spreedCapabilities chatApiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) + participantPermissions = ParticipantPermissions(spreedCapabilities, currentConversation!!) invalidateOptionsMenu() - initMessageInputView() + checkShowCallButtons() + checkShowMessageInputView() + checkLobbyState() + updateRoomTimerHandler() + } + + is ChatViewModel.GetCapabilitiesInitialLoadState -> { + spreedCapabilities = state.spreedCapabilities + chatApiVersion = ApiUtils.getChatApiVersion(spreedCapabilities, intArrayOf(1)) + participantPermissions = ParticipantPermissions(spreedCapabilities, currentConversation!!) + + supportFragmentManager.commit { + setReorderingAllowed(true) // optimizes out redundant replace operations + replace(R.id.fragment_container_activity_chat, MessageInputFragment()) + } + + joinRoomWithPassword() if (conversationUser?.userId != "?" && CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.MENTION_FLAG) ) { - binding.chatToolbar.setOnClickListener { v -> showConversationInfoScreen() } + binding.chatToolbar.setOnClickListener { _ -> showConversationInfoScreen() } } if (adapter == null) { initAdapter() binding.messagesListView.setAdapter(adapter) + layoutManager = binding.messagesListView.layoutManager as LinearLayoutManager? } - layoutManager = binding.messagesListView.layoutManager as LinearLayoutManager? - loadAvatarForStatusBar() - setActionBarTitle() - participantPermissions = ParticipantPermissions(spreedCapabilities, 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 - ) + setActionBarTitle() + updateRoomTimerHandler() chatViewModel.refreshChatParams( setupFieldsForPullChatMessages( @@ -776,8 +639,6 @@ class ChatActivity : is ChatViewModel.LeaveRoomSuccessState -> { logConversationInfos("leaveRoom#onNext") - sendStopTypingMessage() - checkingLobbyStatus = false if (getRoomInfoTimerHandler != null) { @@ -803,9 +664,9 @@ class ChatActivity : } } - chatViewModel.sendChatMessageViewState.observe(this) { state -> + messageInputViewModel.sendChatMessageViewState.observe(this) { state -> when (state) { - is ChatViewModel.SendChatMessageSuccessState -> { + is MessageInputViewModel.SendChatMessageSuccessState -> { myFirstMessage = state.message if (binding.popupBubbleView.isShown == true) { @@ -814,7 +675,7 @@ class ChatActivity : binding.messagesListView.smoothScrollToPosition(0) } - is ChatViewModel.SendChatMessageErrorState -> { + is MessageInputViewModel.SendChatMessageErrorState -> { if (state.e is HttpException) { val code = state.e.code() if (code.toString().startsWith("2")) { @@ -895,15 +756,19 @@ class ChatActivity : val chatOverall = state.response.body() as ChatOverall? var chatMessageList = chatOverall?.ocs!!.data!! + val newXChatLastCommonRead = state.response.headers()["X-Chat-Last-Common-Read"]?.let { + Integer.parseInt(it) + } + processHeaderChatLastGiven(state.response, state.lookIntoFuture) chatMessageList = handleSystemMessages(chatMessageList) - if (chatMessageList.size == 0) { + if (chatMessageList.isEmpty()) { chatViewModel.refreshChatParams( setupFieldsForPullChatMessages( true, - globalLastKnownFutureMessageId, + newXChatLastCommonRead, true ) ) @@ -935,10 +800,6 @@ class ChatActivity : collapseSystemMessages() } - val newXChatLastCommonRead = state.response.headers()["X-Chat-Last-Common-Read"]?.let { - Integer.parseInt(it) - } - updateReadStatusOfAllMessages(newXChatLastCommonRead) processCallStartedMessages(chatMessageList) @@ -958,7 +819,7 @@ class ChatActivity : chatViewModel.refreshChatParams( setupFieldsForPullChatMessages( true, - globalLastKnownFutureMessageId, + globalLastKnownPastMessageId, true ) ) @@ -968,7 +829,7 @@ class ChatActivity : chatViewModel.refreshChatParams( setupFieldsForPullChatMessages( true, - globalLastKnownFutureMessageId, + globalLastKnownPastMessageId, true ) ) @@ -1026,9 +887,9 @@ class ChatActivity : } } - chatViewModel.editMessageViewState.observe(this) { state -> + messageInputViewModel.editMessageViewState.observe(this) { state -> when (state) { - is ChatViewModel.EditMessageSuccessState -> { + is MessageInputViewModel.EditMessageSuccessState -> { when (state.messageEdited.ocs?.meta?.statusCode) { HTTP_BAD_REQUEST -> { Snackbar.make( @@ -1054,16 +915,46 @@ class ChatActivity : ).show() } } - clearEditUI() } - is ChatViewModel.EditMessageErrorState -> { + is MessageInputViewModel.EditMessageErrorState -> { Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() } else -> {} } } + + chatViewModel.getVoiceRecordingLocked.observe(this) { showContiniousVoiceRecording -> + if (showContiniousVoiceRecording) { + binding.voiceRecordingLock.visibility = View.GONE + supportFragmentManager.commit { + setReorderingAllowed(true) // apparently used for optimizations + replace(R.id.fragment_container_activity_chat, MessageInputVoiceRecordingFragment()) + } + } else { + supportFragmentManager.commit { + setReorderingAllowed(true) + replace(R.id.fragment_container_activity_chat, MessageInputFragment()) + } + } + } + + chatViewModel.getVoiceRecordingInProgress.observe(this) { voiceRecordingInProgress -> + VibrationUtils.vibrateShort(context) + binding.voiceRecordingLock.visibility = if ( + voiceRecordingInProgress && + chatViewModel.getVoiceRecordingLocked.value != true + ) { + View.VISIBLE + } else { + View.GONE + } + } + + chatViewModel.recordTouchObserver.observe(this) { y -> + binding.voiceRecordingLock.y -= y + } } @Suppress("Detekt.TooGenericExceptionCaught") @@ -1078,10 +969,6 @@ class ChatActivity : webSocketInstance?.getSignalingMessageReceiver()?.addListener(localParticipantMessageListener) webSocketInstance?.getSignalingMessageReceiver()?.addListener(conversationMessageListener) - initSmileyKeyboardToggler() - - themeMessageInputView() - cancelNotificationsForCurrentConversation() chatViewModel.getRoom(conversationUser!!, roomToken) @@ -1119,8 +1006,6 @@ class ChatActivity : 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) @@ -1150,116 +1035,6 @@ class ChatActivity : viewThemeUtils.material.colorToolbarOverflowIcon(binding.chatToolbar) } - private fun initMessageInputView() { - if (binding.messageInputView.inputEditText?.filters?.isEmpty() == true) { - val filters = arrayOfNulls(1) - val lengthFilter = CapabilitiesUtil.getMessageMaxLength(spreedCapabilities) - - 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).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 (sharedText.isNotEmpty()) { - binding.messageInputView.inputEditText?.setText(sharedText) - } - - binding.messageInputView.setAttachmentsListener { - AttachmentDialog(this, this).show() - } - - binding.messageInputView.button?.setOnClickListener { - submitMessage(false) - } - - if (CapabilitiesUtil.hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.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(spreedCapabilities, intArrayOf(1)) - } - - chatViewModel.editChatMessage( - credentials!!, - ApiUtils.getUrlForChatMessage( - apiVersion, - conversationUser?.baseUrl!!, - roomToken, - message.id - ), - editedMessageText - ) - } - private fun getLastAdapterId(): Int { var lastId = 0 if (adapter?.items?.size != 0) { @@ -1273,68 +1048,6 @@ class ChatActivity : return lastId } - private fun setEditUI() { - binding.messageInputView.messageSendButton.visibility = View.GONE - binding.messageInputView.recordAudioButton.visibility = View.GONE - binding.messageInputView.editMessageButton.visibility = View.VISIBLE - binding.editView.editMessageView.visibility = View.VISIBLE - binding.messageInputView.attachmentButton.visibility = View.GONE - } - - private fun clearEditUI() { - binding.messageInputView.editMessageButton.visibility = View.GONE - editableBehaviorSubject.onNext(false) - binding.messageInputView.inputEditText.setText("") - binding.editView.editMessageView.visibility = View.GONE - binding.messageInputView.attachmentButton.visibility = View.VISIBLE - } - - private fun themeMessageInputView() { - binding.messageInputView.button?.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) } - - binding.messageInputView.findViewById(R.id.cancelReplyButton)?.setOnClickListener { - cancelReply() - } - - binding.messageInputView.findViewById(R.id.cancelReplyButton)?.let { - viewThemeUtils.platform - .themeImageButton(it) - } - - binding.messageInputView.findViewById(R.id.playPauseBtn)?.let { - viewThemeUtils.material.colorMaterialButtonText(it) - } - - binding.messageInputView.findViewById(R.id.seekbar)?.let { - viewThemeUtils.platform.themeHorizontalSeekBar(it) - } - - binding.messageInputView.findViewById(R.id.deleteVoiceRecording)?.let { - viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) - } - binding.messageInputView.findViewById(R.id.sendVoiceRecording)?.let { - viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) - } - - binding.messageInputView.findViewById(R.id.microphoneEnabledInfo)?.let { - viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) - } - - binding.messageInputView.findViewById(R.id.voice_preview_container)?.let { - viewThemeUtils.talk.themeOutgoingMessageBubble(it, true, false) - } - - binding.messageInputView.findViewById(R.id.micInputCloud)?.let { - viewThemeUtils.talk.themeMicInputCloud(it) - } - binding.messageInputView.findViewById(R.id.editMessageButton)?.let { - viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) - } - binding.editView.clearEdit.let { - viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) - } - } - private fun setupActionBar() { setSupportActionBar(binding.chatToolbar) binding.chatToolbar.setNavigationOnClickListener { @@ -1517,445 +1230,6 @@ class ChatActivity : 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: Float - var deltaY: Float - - 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) { - audioFocusRequest(false) { - 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() - } - audioFocusRequest(true) { - voicePreviewMediaPlayer!!.start() - voicePreviewObjectAnimator!!.start() - handleBecomingNoisyBroadcast(register = true) - } - } - - setOnCompletionListener { - stopPreviewVoicePlaying() - } - } - } - - private fun startPreviewVoicePlaying() { - Log.d(TAG, "started preview voice recording") - if (voicePreviewMediaPlayer == null) { - initPreviewVoiceRecording() - } else { - audioFocusRequest(true) { - voicePreviewMediaPlayer!!.start() - voicePreviewObjectAnimator!!.resume() - handleBecomingNoisyBroadcast(register = true) - } - } - } - - private fun pausePreviewVoicePlaying() { - Log.d(TAG, "paused preview voice recording") - audioFocusRequest(false) { - voicePreviewMediaPlayer!!.pause() - voicePreviewObjectAnimator!!.pause() - handleBecomingNoisyBroadcast(register = false) - } - } - - 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() - audioFocusRequest(false) { - voicePreviewMediaPlayer!!.stop() - voicePreviewMediaPlayer!!.release() - voicePreviewMediaPlayer = null - handleBecomingNoisyBroadcast(register = false) - } - } - } - - 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(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 { @@ -2023,18 +1297,21 @@ class ChatActivity : if (participantNames.size > 0) { binding.typingIndicatorWrapper.animate() - .translationY(binding.messageInputView.y - DisplayUtils.convertDpToPixel(18f, context)) + .translationY(binding.fragmentContainerActivityChat.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) + .translationY(binding.fragmentContainerActivityChat.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)) + .translationY( + binding.fragmentContainerActivityChat.y + + DisplayUtils.convertDpToPixel(15f, context) + ) .setInterpolator(AccelerateDecelerateInterpolator()) .duration = TYPING_INDICATOR_ANIMATION_DURATION } @@ -2042,62 +1319,6 @@ class ChatActivity : } } - 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 - - val concurrentSafeHashMap = webSocketInstance?.getUserMap()!! - for ((sessionId, _) in concurrentSafeHashMap) { - val ncSignalingMessage = NCSignalingMessage() - ncSignalingMessage.to = sessionId - ncSignalingMessage.type = TYPING_STOPPED_SIGNALING_MESSAGE_TYPE - signalingMessageSender!!.send(ncSignalingMessage) - } - } - } - private fun isTypingStatusEnabled(): Boolean { return webSocketInstance != null && !CapabilitiesUtil.isTypingStatusPrivate(conversationUser!!) @@ -2114,7 +1335,7 @@ class ChatActivity : override fun showReplyUI(position: Int) { val chatMessage = adapter?.items?.getOrNull(position)?.item as ChatMessage? if (chatMessage != null) { - replyToMessage(chatMessage) + messageInputViewModel.reply(chatMessage) } } } @@ -2211,6 +1432,24 @@ class ChatActivity : currentConversation != null && currentConversation?.type != null && currentConversation?.type == ConversationType.ROOM_PUBLIC_CALL + private fun updateRoomTimerHandler() { + 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 + ) + } + private fun switchToRoom(token: String, startCallAfterRoomSwitch: Boolean, isVoiceOnlyCall: Boolean) { if (conversationUser != null) { runOnUiThread { @@ -2246,27 +1485,6 @@ class ChatActivity : } } - 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) @@ -2296,73 +1514,6 @@ class ChatActivity : } } - private fun getAudioFocusChangeListener(): AudioManager.OnAudioFocusChangeListener { - return AudioManager.OnAudioFocusChangeListener { flag -> - when (flag) { - AudioManager.AUDIOFOCUS_LOSS -> { - chatViewModel.isPausedDueToBecomingNoisy = false - if (isVoicePreviewPlaying) { - stopPreviewVoicePlaying() - } - if (currentlyPlayedVoiceMessage != null) { - stopMediaPlayer(currentlyPlayedVoiceMessage!!) - } - } - - AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { - chatViewModel.isPausedDueToBecomingNoisy = false - if (isVoicePreviewPlaying) { - pausePreviewVoicePlaying() - } - if (currentlyPlayedVoiceMessage != null) { - pausePlayback(currentlyPlayedVoiceMessage!!) - } - } - } - } - } - - private fun audioFocusRequest(shouldRequestFocus: Boolean, onGranted: () -> Unit) { - if (chatViewModel.isPausedDueToBecomingNoisy) { - onGranted() - return - } - val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager - val duration = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE - - val isGranted: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val focusRequest = AudioFocusRequest.Builder(duration) - .setOnAudioFocusChangeListener(audioFocusChangeListener) - .build() - if (shouldRequestFocus) { - audioManager.requestAudioFocus(focusRequest) - } else { - audioManager.abandonAudioFocusRequest(focusRequest) - } - } else { - @Deprecated("This method was deprecated in API level 26.") - if (shouldRequestFocus) { - audioManager.requestAudioFocus(audioFocusChangeListener, AudioManager.STREAM_MUSIC, duration) - } else { - audioManager.abandonAudioFocus(audioFocusChangeListener) - } - } - if (isGranted == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - onGranted() - } - } - - private fun handleBecomingNoisyBroadcast(register: Boolean) { - if (register && !chatViewModel.receiverRegistered) { - registerReceiver(noisyAudioStreamReceiver, IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)) - chatViewModel.receiverRegistered = true - } else if (!chatViewModel.receiverUnregistered) { - unregisterReceiver(noisyAudioStreamReceiver) - chatViewModel.receiverUnregistered = true - chatViewModel.receiverRegistered = false - } - } - private fun startPlayback(message: ChatMessage, doPlay: Boolean = true) { if (!active) { // don't begin to play voice message if screen is not visible anymore. @@ -2376,9 +1527,8 @@ class ChatActivity : mediaPlayer?.let { if (!it.isPlaying && doPlay) { - audioFocusRequest(true) { + chatViewModel.audioRequest(true) { it.start() - handleBecomingNoisyBroadcast(register = true) } } @@ -2418,9 +1568,8 @@ class ChatActivity : private fun pausePlayback(message: ChatMessage) { if (mediaPlayer!!.isPlaying) { - audioFocusRequest(false) { + chatViewModel.audioRequest(false) { mediaPlayer!!.pause() - handleBecomingNoisyBroadcast(register = false) } } @@ -2482,9 +1631,8 @@ class ChatActivity : mediaPlayer?.let { if (it.isPlaying) { Log.d(TAG, "media player is stopped") - audioFocusRequest(false) { + chatViewModel.audioRequest(false) { it.stop() - handleBecomingNoisyBroadcast(register = false) } } } @@ -2609,223 +1757,14 @@ class ChatActivity : } } - @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") - audioFocusRequest(false) { - micInputAudioRecorder.stop() - micInputAudioRecorder.release() - } - isMicInputAudioThreadRunning = false - micInputAudioRecordThread = null - } - } - - private fun isRecordAudioPermissionGranted(): Boolean { + 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 { - audioFocusRequest(true) { - 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) { - audioFocusRequest(false) { - 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() { + fun requestRecordAudioPermissions() { requestPermissions( arrayOf( Manifest.permission.RECORD_AUDIO @@ -2888,9 +1827,9 @@ class ChatActivity : shouldShowLobby() || !participantPermissions.hasChatPermission() ) { - binding.messageInputView.visibility = View.GONE + binding.fragmentContainerActivityChat.visibility = View.GONE } else { - binding.messageInputView.visibility = View.VISIBLE + binding.fragmentContainerActivityChat.visibility = View.VISIBLE } } @@ -2943,7 +1882,7 @@ class ChatActivity : if (shouldShowLobby()) { binding.lobby.lobbyView.visibility = View.VISIBLE binding.messagesListView.visibility = View.GONE - binding.messageInputView.visibility = View.GONE + binding.fragmentContainerActivityChat.visibility = View.GONE binding.progressBar.visibility = View.GONE val sb = StringBuilder() @@ -2969,14 +1908,12 @@ class ChatActivity : } else { binding.lobby.lobbyView.visibility = View.GONE binding.messagesListView.visibility = View.VISIBLE - binding.messageInputView.inputEditText?.visibility = View.VISIBLE + binding.fragmentContainerActivityChat.visibility = View.VISIBLE } } else { binding.lobby.lobbyView.visibility = View.GONE binding.messagesListView.visibility = View.VISIBLE - if (!isVoiceRecordingLocked) { - binding.messageInputView.inputEditText?.visibility = View.VISIBLE - } + binding.fragmentContainerActivityChat.visibility = View.VISIBLE } } @@ -3262,21 +2199,7 @@ class ChatActivity : if (token == "") room = roomToken else room = token - try { - require(fileUri.isNotEmpty()) - UploadAndShareFilesWorker.upload( - fileUri, - room, - 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) - } + chatViewModel.uploadFile(fileUri, room, currentConversation?.displayName!!, metaData) } private fun showLocalFilePicker() { @@ -3332,41 +2255,12 @@ class ChatActivity : 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, chatApiVersion) - val callback = MentionAutocompleteCallback( - this, - conversationUser!!, - binding.messageInputView.inputEditText, - viewThemeUtils - ) - - if (mentionAutocomplete == null && binding.messageInputView.inputEditText != null) { - mentionAutocomplete = Autocomplete.on(binding.messageInputView.inputEditText) - .with(elevation) - .with(backgroundDrawable) - .with(CharPolicy('@')) - .with(presenter) - .with(callback) - .build() - } - } - } - private fun validSessionId(): Boolean { return currentConversation != null && sessionIdAfterRoomJoined?.isNotEmpty() == true && sessionIdAfterRoomJoined != "0" } - private fun cancelReply() { - binding.messageInputView.findViewById(R.id.quotedChatMessageView)?.visibility = View.GONE - binding.messageInputView.findViewById(R.id.attachmentButton)?.visibility = View.VISIBLE - } - @Suppress("Detekt.TooGenericExceptionCaught") private fun cancelNotificationsForCurrentConversation() { if (conversationUser != null) { @@ -3542,59 +2436,6 @@ class ChatActivity : ) } - 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 - val needsQuotes = mentionId.contains(" ") || - mentionId.contains("@") || - mentionId.startsWith("guest/") || - mentionId.startsWith("group/") - - if (needsQuotes) { - mentionId = "\"" + mentionId + "\"" - } - editable.replace(editable.getSpanStart(mentionSpan), editable.getSpanEnd(mentionSpan), "@$mentionId") - } - - binding.messageInputView.inputEditText?.setText("") - sendStopTypingMessage() - val replyMessageId: Int? = findViewById(R.id.quotedChatMessageView)?.tag as Int? - sendMessage( - editable, - if (findViewById(R.id.quotedChatMessageView)?.visibility == View.VISIBLE) { - replyMessageId - } else { - null - }, - sendWithoutNotification - ) - cancelReply() - } - } - - private fun sendMessage(message: CharSequence, replyTo: Int?, sendWithoutNotification: Boolean) { - if (conversationUser != null) { - chatViewModel.sendChatMessage( - credentials!!, - ApiUtils.getUrlForChat(chatApiVersion, conversationUser!!.baseUrl!!, roomToken), - message, - conversationUser!!.displayName ?: "", - replyTo ?: 0, - sendWithoutNotification - ) - } - showMicrophoneButton(true) - } - private fun setupWebsocket() { if (conversationUser == null) { return @@ -3966,7 +2807,7 @@ class ChatActivity : super.onCreateOptionsMenu(menu) menuInflater.inflate(R.menu.menu_conversation, menu) - binding.messageInputView.context?.let { + context.let { viewThemeUtils.platform.colorToolbarMenuIcon( it, menu.findItem(R.id.conversation_voice_call) @@ -4527,68 +3368,6 @@ class ChatActivity : BuildConfig.DEBUG } - fun replyToMessage(message: IMessage?) { - val chatMessage = message as ChatMessage? - chatMessage?.let { - binding.messageInputView.findViewById(R.id.attachmentButton)?.visibility = - View.GONE - binding.messageInputView.findViewById(R.id.cancelReplyButton)?.visibility = - View.VISIBLE - - val quotedMessage = binding.messageInputView.findViewById(R.id.quotedMessage) - - quotedMessage?.maxLines = 2 - quotedMessage?.ellipsize = TextUtils.TruncateAt.END - quotedMessage?.text = it.text - binding.messageInputView.findViewById(R.id.quotedMessageAuthor)?.text = - it.actorDisplayName ?: context.getText(R.string.nc_nick_guest) - - conversationUser?.let { - val quotedMessageImage = binding.messageInputView.findViewById(R.id.quotedMessageImage) - chatMessage.imageUrl?.let { previewImageUrl -> - quotedMessageImage?.visibility = View.VISIBLE - - val px = TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - QUOTED_MESSAGE_IMAGE_MAX_HEIGHT, - resources?.displayMetrics - ) - - quotedMessageImage?.maxHeight = px.toInt() - val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams - layoutParams.flexGrow = 0f - quotedMessageImage.layoutParams = layoutParams - quotedMessageImage.load(previewImageUrl) { - addHeader("Authorization", credentials!!) - } - } ?: run { - binding.messageInputView.findViewById(R.id.quotedMessageImage)?.visibility = View.GONE - } - } - - val quotedChatMessageView = - binding.messageInputView.findViewById(R.id.quotedChatMessageView) - quotedChatMessageView?.tag = message?.jsonMessageId - quotedChatMessageView?.visibility = View.VISIBLE - } - } - - private fun showMicrophoneButton(show: Boolean) { - if (show && CapabilitiesUtil.hasSpreedFeatureCapability( - spreedCapabilities, - SpreedFeatures.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 @@ -4864,30 +3643,6 @@ class ChatActivity : startActivity(shareIntent) } - fun editMessage(message: ChatMessage) { - editableBehaviorSubject.onNext(true) - editMessage = message - initMessageInputView() - - setEditUI() - - val editableText = Editable.Factory.getInstance().newEditable(editMessage.message) - binding.messageInputView.inputEditText.text = editableText - binding.messageInputView.inputEditText.setSelection(editableText.length) - binding.editView.editMessage.text = editMessage.message - - binding.messageInputView.editMessageButton.setOnClickListener { - if (editMessage.message == editedTextBehaviorSubject.value!!) { - clearEditUI() - return@setOnClickListener - } - editMessageAPI(editMessage, editedMessageText = editedTextBehaviorSubject.value!!) - } - binding.editView.clearEdit.setOnClickListener { - clearEditUI() - } - } - companion object { val TAG = ChatActivity::class.simpleName private const val CONTENT_TYPE_CALL_STARTED: Byte = 1 diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt new file mode 100644 index 0000000000..ad21845f24 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -0,0 +1,821 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Julius Linus juliuslinus1@gmail.com + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat + +import android.content.res.Resources +import android.graphics.drawable.ColorDrawable +import android.os.Build +import android.os.Bundle +import android.os.CountDownTimer +import android.os.SystemClock +import android.text.Editable +import android.text.InputFilter +import android.text.TextUtils +import android.text.TextWatcher +import android.util.Log +import android.util.TypedValue +import android.view.Gravity +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.animation.AlphaAnimation +import android.view.animation.Animation +import android.view.animation.LinearInterpolator +import android.widget.ImageButton +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.PopupMenu +import android.widget.RelativeLayout +import android.widget.SeekBar +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.view.ContextThemeWrapper +import androidx.core.content.ContextCompat +import androidx.core.widget.doAfterTextChanged +import androidx.emoji2.widget.EmojiTextView +import androidx.fragment.app.Fragment +import autodagger.AutoInjector +import coil.load +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.R +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication +import com.nextcloud.talk.callbacks.MentionAutocompleteCallback +import com.nextcloud.talk.chat.viewmodels.ChatViewModel +import com.nextcloud.talk.databinding.FragmentMessageInputBinding +import com.nextcloud.talk.jobs.UploadAndShareFilesWorker +import com.nextcloud.talk.models.json.chat.ChatMessage +import com.nextcloud.talk.models.json.mention.Mention +import com.nextcloud.talk.models.json.signaling.NCSignalingMessage +import com.nextcloud.talk.presenters.MentionAutocompletePresenter +import com.nextcloud.talk.ui.MicInputCloud +import com.nextcloud.talk.ui.dialog.AttachmentDialog +import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.users.UserManager +import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.CapabilitiesUtil +import com.nextcloud.talk.utils.CharPolicy +import com.nextcloud.talk.utils.ImageEmojiEditText +import com.nextcloud.talk.utils.SpreedFeatures +import com.nextcloud.talk.utils.text.Spans +import com.otaliastudios.autocomplete.Autocomplete +import com.stfalcon.chatkit.commons.models.IMessage +import com.vanniktech.emoji.EmojiPopup +import java.util.Objects +import javax.inject.Inject + +@Suppress("LongParameterList", "TooManyFunctions") +@AutoInjector(NextcloudTalkApplication::class) +class MessageInputFragment : Fragment() { + + companion object { + fun newInstance() = MessageInputFragment() + private val TAG: String = MessageInputFragment::class.java.simpleName + 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" + const val VOICE_MESSAGE_META_DATA = "{\"messageType\":\"voice-message\"}" + private const val QUOTED_MESSAGE_IMAGE_MAX_HEIGHT = 96f + private const val MENTION_AUTO_COMPLETE_ELEVATION = 6f + private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000 + private const val ANIMATION_DURATION: Long = 750 + private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -150 + private const val VOICE_RECORD_LOCK_THRESHOLD: Float = 100f + private const val INCREMENT = 8f + private const val CURSOR_KEY = "_cursor" + } + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + @Inject + lateinit var userManager: UserManager + + lateinit var binding: FragmentMessageInputBinding + private var typedWhileTypingTimerIsRunning: Boolean = false + private var typingTimer: CountDownTimer? = null + private lateinit var chatActivity: ChatActivity + private var emojiPopup: EmojiPopup? = null + private var mentionAutocomplete: Autocomplete<*>? = null + private var xcounter = 0f + private var ycounter = 0f + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sharedApplication!!.componentApplication.inject(this) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentMessageInputBinding.inflate(inflater) + chatActivity = requireActivity() as ChatActivity + themeMessageInputView() + initMessageInputView() + initSmileyKeyboardToggler() + setupMentionAutocomplete() + initVoiceRecordButton() + restoreState() + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + saveState() + if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { + mentionAutocomplete?.dismissPopup() + } + clearEditUI() + cancelReply() + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + initObservers() + } + + private fun initObservers() { + Log.d(TAG, "LifeCyclerOwner is: ${viewLifecycleOwner.lifecycle}") + chatActivity.messageInputViewModel.getReplyChatMessage.observe(viewLifecycleOwner) { message -> + message?.let { replyToMessage(message) } + } + + chatActivity.messageInputViewModel.getEditChatMessage.observe(viewLifecycleOwner) { message -> + message?.let { setEditUI(it as ChatMessage) } + } + + chatActivity.chatViewModel.leaveRoomViewState.observe(viewLifecycleOwner) { state -> + when (state) { + is ChatViewModel.LeaveRoomSuccessState -> sendStopTypingMessage() + else -> {} + } + } + } + + private fun restoreState() { + requireContext().getSharedPreferences(chatActivity.localClassName, AppCompatActivity.MODE_PRIVATE).apply { + val text = getString(chatActivity.roomToken, "") + val cursor = getInt(chatActivity.roomToken + CURSOR_KEY, 0) + binding.fragmentMessageInputView.messageInput.setText(text) + binding.fragmentMessageInputView.messageInput.setSelection(cursor) + } + } + + private fun saveState() { + val text = binding.fragmentMessageInputView.messageInput.text.toString() + val cursor = binding.fragmentMessageInputView.messageInput.selectionStart + val previous = requireContext().getSharedPreferences( + chatActivity.localClassName, + AppCompatActivity + .MODE_PRIVATE + ).getString(chatActivity.roomToken, "null") + + if (text != previous) { + requireContext().getSharedPreferences( + chatActivity.localClassName, + AppCompatActivity.MODE_PRIVATE + ).edit().apply { + putString(chatActivity.roomToken, text) + putInt(chatActivity.roomToken + CURSOR_KEY, cursor) + apply() + } + } + } + + private fun initMessageInputView() { + if (!chatActivity.active) return + + val filters = arrayOfNulls(1) + val lengthFilter = CapabilitiesUtil.getMessageMaxLength(chatActivity.spreedCapabilities) + + binding.fragmentEditView.editMessageView.visibility = View.GONE + binding.fragmentMessageInputView.setPadding(0, 0, 0, 0) + + filters[0] = InputFilter.LengthFilter(lengthFilter) + binding.fragmentMessageInputView.inputEditText?.filters = filters + + binding.fragmentMessageInputView.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.fragmentMessageInputView.inputEditText?.error = String.format( + Objects.requireNonNull(resources).getString(R.string.nc_limit_hit), + lengthFilter.toString() + ) + } else { + binding.fragmentMessageInputView.inputEditText?.error = null + } + + val editable = binding.fragmentMessageInputView.inputEditText?.editableText + + if (editable != null && binding.fragmentMessageInputView.inputEditText != null) { + val mentionSpans = editable.getSpans( + 0, + binding.fragmentMessageInputView.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.fragmentMessageInputView.inputEditText as ImageEmojiEditText).onCommitContentListener = { + uploadFile(it.toString(), false) + } + + if (chatActivity.sharedText.isNotEmpty()) { + binding.fragmentMessageInputView.inputEditText?.setText(chatActivity.sharedText) + } + + binding.fragmentMessageInputView.setAttachmentsListener { + AttachmentDialog(requireActivity(), requireActivity() as ChatActivity).show() + } + + binding.fragmentMessageInputView.button?.setOnClickListener { + submitMessage(false) + } + + binding.fragmentMessageInputView.editMessageButton.setOnClickListener { + val text = binding.fragmentMessageInputView.inputEditText.text.toString() + val message = chatActivity.messageInputViewModel.getEditChatMessage.value as ChatMessage + if (message.message!!.trim() != text.trim()) { + editMessageAPI(message, text) + } + clearEditUI() + } + binding.fragmentEditView.clearEdit.setOnClickListener { + clearEditUI() + } + + if (CapabilitiesUtil.hasSpreedFeatureCapability(chatActivity.spreedCapabilities, SpreedFeatures.SILENT_SEND)) { + binding.fragmentMessageInputView.button?.setOnLongClickListener { + showSendButtonMenu() + true + } + } + + binding.fragmentMessageInputView.button?.contentDescription = + resources.getString(R.string.nc_description_send_message_button) + } + + @Suppress("ClickableViewAccessibility", "CyclomaticComplexMethod", "LongMethod") + private fun initVoiceRecordButton() { + binding.fragmentMessageInputView.messageSendButton.visibility = View.GONE + binding.fragmentMessageInputView.inputEditText.doAfterTextChanged { + binding.fragmentMessageInputView.recordAudioButton.visibility = + if (binding.fragmentMessageInputView.inputEditText.text.isEmpty()) View.VISIBLE else View.GONE + + binding.fragmentMessageInputView.messageSendButton.visibility = + if (binding.fragmentMessageInputView.inputEditText.text.isEmpty() || + binding.fragmentEditView.editMessageView.visibility == View.VISIBLE + ) { + View.GONE + } else { + View.VISIBLE + } + } + + var prevDx = 0f + var voiceRecordStartTime = 0L + var voiceRecordEndTime: Long + binding.fragmentMessageInputView.recordAudioButton.setOnTouchListener { v, event -> + v?.performClick() + when (event?.action) { + MotionEvent.ACTION_DOWN -> { + if (!chatActivity.isRecordAudioPermissionGranted()) { + chatActivity.requestRecordAudioPermissions() + return@setOnTouchListener true + } + if (!chatActivity.permissionUtil.isFilesPermissionGranted()) { + UploadAndShareFilesWorker.requestStoragePermission(chatActivity) + return@setOnTouchListener true + } + + val base = SystemClock.elapsedRealtime() + voiceRecordStartTime = System.currentTimeMillis() + binding.fragmentMessageInputView.audioRecordDuration.base = base + chatActivity.messageInputViewModel.setRecordingTime(base) + binding.fragmentMessageInputView.audioRecordDuration.start() + chatActivity.chatViewModel.startAudioRecording(requireContext(), chatActivity.currentConversation!!) + showRecordAudioUi(true) + } + + MotionEvent.ACTION_CANCEL -> { + Log.d(TAG, "ACTION_CANCEL") + if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false || + !chatActivity.isRecordAudioPermissionGranted() + ) { + return@setOnTouchListener true + } + + showRecordAudioUi(false) + if (chatActivity.chatViewModel.getVoiceRecordingLocked.value != true) { // can also be null + chatActivity.chatViewModel.stopAndDiscardAudioRecording() + } + } + + MotionEvent.ACTION_UP -> { + Log.d(TAG, "ACTION_UP") + if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false || + chatActivity.chatViewModel.getVoiceRecordingLocked.value == true || + !chatActivity.isRecordAudioPermissionGranted() + ) { + return@setOnTouchListener false + } + showRecordAudioUi(false) + + voiceRecordEndTime = System.currentTimeMillis() + val voiceRecordDuration = voiceRecordEndTime - voiceRecordStartTime + if (voiceRecordDuration < MINIMUM_VOICE_RECORD_DURATION) { + Snackbar.make( + binding.root, + requireContext().getString(R.string.nc_voice_message_hold_to_record_info), + Snackbar.LENGTH_SHORT + ).show() + chatActivity.chatViewModel.stopAndDiscardAudioRecording() + return@setOnTouchListener false + } else { + chatActivity.chatViewModel.stopAndSendAudioRecording( + chatActivity.roomToken, + chatActivity.currentConversation!!.displayName!!, + VOICE_MESSAGE_META_DATA + ) + } + resetSlider() + } + + MotionEvent.ACTION_MOVE -> { + if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false || + !chatActivity.isRecordAudioPermissionGranted() + ) { + return@setOnTouchListener false + } + + if (event.x < VOICE_RECORD_CANCEL_SLIDER_X) { + chatActivity.chatViewModel.stopAndDiscardAudioRecording() + showRecordAudioUi(false) + resetSlider() + return@setOnTouchListener true + } + if (event.x < 0f) { + val dX = event.x + if (dX < prevDx) { // left + binding.fragmentMessageInputView.slideToCancelDescription.x -= INCREMENT + xcounter += INCREMENT + } else { // right + binding.fragmentMessageInputView.slideToCancelDescription.x += INCREMENT + xcounter -= INCREMENT + } + + prevDx = dX + } + + if (event.y < 0f) { + chatActivity.chatViewModel.postToRecordTouchObserver(INCREMENT) + ycounter += INCREMENT + } + + if (ycounter >= VOICE_RECORD_LOCK_THRESHOLD) { + resetSlider() + binding.fragmentMessageInputView.recordAudioButton.isEnabled = false + chatActivity.chatViewModel.setVoiceRecordingLocked(true) + binding.fragmentMessageInputView.recordAudioButton.isEnabled = true + } + } + } + v?.onTouchEvent(event) ?: true + } + } + + private fun resetSlider() { + binding.fragmentMessageInputView.audioRecordDuration.stop() + binding.fragmentMessageInputView.audioRecordDuration.clearAnimation() + binding.fragmentMessageInputView.slideToCancelDescription.x += xcounter + chatActivity.chatViewModel.postToRecordTouchObserver(-ycounter) + xcounter = 0f + ycounter = 0f + } + + private fun setupMentionAutocomplete() { + val elevation = MENTION_AUTO_COMPLETE_ELEVATION + resources.let { + val backgroundDrawable = ColorDrawable(it.getColor(R.color.bg_default, null)) + val presenter = MentionAutocompletePresenter( + requireContext(), + chatActivity.roomToken, + chatActivity.chatApiVersion + ) + val callback = MentionAutocompleteCallback( + requireContext(), + chatActivity.conversationUser!!, + binding.fragmentMessageInputView.inputEditText, + viewThemeUtils + ) + + if (mentionAutocomplete == null && binding.fragmentMessageInputView.inputEditText != null) { + mentionAutocomplete = + Autocomplete.on(binding.fragmentMessageInputView.inputEditText) + .with(elevation) + .with(backgroundDrawable) + .with(CharPolicy('@')) + .with(presenter) + .with(callback) + .build() + } + } + } + + private fun showRecordAudioUi(show: Boolean) { + if (show) { + val animation: Animation = AlphaAnimation(1.0f, 0.0f) + animation.duration = ANIMATION_DURATION + animation.interpolator = LinearInterpolator() + animation.repeatCount = Animation.INFINITE + animation.repeatMode = Animation.REVERSE + binding.fragmentMessageInputView.microphoneEnabledInfo.startAnimation(animation) + + binding.fragmentMessageInputView.microphoneEnabledInfo.visibility = View.VISIBLE + binding.fragmentMessageInputView.microphoneEnabledInfoBackground.visibility = View.VISIBLE + binding.fragmentMessageInputView.audioRecordDuration.visibility = View.VISIBLE + binding.fragmentMessageInputView.slideToCancelDescription.visibility = View.VISIBLE + binding.fragmentMessageInputView.attachmentButton.visibility = View.GONE + binding.fragmentMessageInputView.smileyButton.visibility = View.GONE + binding.fragmentMessageInputView.messageInput.visibility = View.GONE + binding.fragmentMessageInputView.messageInput.hint = "" + } else { + binding.fragmentMessageInputView.microphoneEnabledInfo.clearAnimation() + + binding.fragmentMessageInputView.microphoneEnabledInfo.visibility = View.GONE + binding.fragmentMessageInputView.microphoneEnabledInfoBackground.visibility = View.GONE + binding.fragmentMessageInputView.audioRecordDuration.visibility = View.GONE + binding.fragmentMessageInputView.slideToCancelDescription.visibility = View.GONE + binding.fragmentMessageInputView.attachmentButton.visibility = View.VISIBLE + binding.fragmentMessageInputView.smileyButton.visibility = View.VISIBLE + binding.fragmentMessageInputView.messageInput.visibility = View.VISIBLE + binding.fragmentMessageInputView.messageInput.hint = + requireContext().resources?.getString(R.string.nc_hint_enter_a_message) + } + } + + private fun initSmileyKeyboardToggler() { + val smileyButton = binding.fragmentMessageInputView.findViewById(R.id.smileyButton) + + emojiPopup = binding.fragmentMessageInputView.inputEditText?.let { + EmojiPopup( + rootView = binding.root, + editText = it, + onEmojiPopupShownListener = { + smileyButton?.setImageDrawable( + ContextCompat.getDrawable(requireContext(), R.drawable.ic_baseline_keyboard_24) + ) + }, + onEmojiPopupDismissListener = { + smileyButton?.setImageDrawable( + ContextCompat.getDrawable(requireContext(), R.drawable.ic_insert_emoticon_black_24dp) + ) + }, + onEmojiClickListener = { + binding.fragmentMessageInputView.inputEditText?.editableText?.append(" ") + } + ) + } + + smileyButton?.setOnClickListener { + emojiPopup?.toggle() + } + } + + private fun replyToMessage(message: IMessage?) { + Log.d(TAG, "Reply") + val chatMessage = message as ChatMessage? + chatMessage?.let { + val view = binding.fragmentMessageInputView + view.findViewById(R.id.attachmentButton)?.visibility = + View.GONE + view.findViewById(R.id.cancelReplyButton)?.visibility = + View.VISIBLE + + val quotedMessage = view.findViewById(R.id.quotedMessage) + + quotedMessage?.maxLines = 2 + quotedMessage?.ellipsize = TextUtils.TruncateAt.END + quotedMessage?.text = it.text + view.findViewById(R.id.quotedMessageAuthor)?.text = + it.actorDisplayName ?: requireContext().getText(R.string.nc_nick_guest) + + chatActivity.conversationUser?.let { + val quotedMessageImage = view.findViewById(R.id.quotedMessageImage) + chatMessage.imageUrl?.let { previewImageUrl -> + quotedMessageImage?.visibility = View.VISIBLE + + val px = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + QUOTED_MESSAGE_IMAGE_MAX_HEIGHT, + resources.displayMetrics + ) + + quotedMessageImage?.maxHeight = px.toInt() + val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams + layoutParams.flexGrow = 0f + quotedMessageImage.layoutParams = layoutParams + quotedMessageImage.load(previewImageUrl) { + addHeader("Authorization", chatActivity.credentials!!) + } + } ?: run { + view.findViewById(R.id.quotedMessageImage)?.visibility = View.GONE + } + } + + val quotedChatMessageView = + view.findViewById(R.id.quotedChatMessageView) + quotedChatMessageView?.tag = message?.jsonMessageId + quotedChatMessageView?.visibility = View.VISIBLE + } + } + + fun updateOwnTypingStatus(typedText: CharSequence) { + fun sendStartTypingSignalingMessage() { + val concurrentSafeHashMap = chatActivity.webSocketInstance?.getUserMap() + if (concurrentSafeHashMap != null) { + for ((sessionId, _) in concurrentSafeHashMap) { + val ncSignalingMessage = NCSignalingMessage() + ncSignalingMessage.to = sessionId + ncSignalingMessage.type = TYPING_STARTED_SIGNALING_MESSAGE_TYPE + chatActivity.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 + + val concurrentSafeHashMap = chatActivity.webSocketInstance?.getUserMap() + if (concurrentSafeHashMap != null) { + for ((sessionId, _) in concurrentSafeHashMap) { + val ncSignalingMessage = NCSignalingMessage() + ncSignalingMessage.to = sessionId + ncSignalingMessage.type = TYPING_STOPPED_SIGNALING_MESSAGE_TYPE + chatActivity.signalingMessageSender?.send(ncSignalingMessage) + } + } + } + } + + private fun isTypingStatusEnabled(): Boolean { + return !CapabilitiesUtil.isTypingStatusPrivate(chatActivity.conversationUser!!) + } + + private fun uploadFile(fileUri: String, isVoiceMessage: Boolean, caption: String = "", token: String = "") { + var metaData = "" + val room: String + + if (!chatActivity.participantPermissions.hasChatPermission()) { + Log.w(ChatActivity.TAG, "uploading file(s) is forbidden because of missing attendee permissions") + return + } + + if (isVoiceMessage) { + metaData = VOICE_MESSAGE_META_DATA + } + + if (caption != "") { + metaData = "{\"caption\":\"$caption\"}" + } + + if (token == "") room = chatActivity.roomToken else room = token + + chatActivity.chatViewModel.uploadFile(fileUri, room, chatActivity.currentConversation!!.displayName!!, metaData) + } + + private fun submitMessage(sendWithoutNotification: Boolean) { + if (binding.fragmentMessageInputView.inputEditText != null) { + val editable = binding.fragmentMessageInputView.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 + val shouldQuote = mentionId.contains(" ") || + mentionId.contains("@") || + mentionId.startsWith("guest/") || + mentionId.startsWith("group/") + if (shouldQuote) { + mentionId = "\"" + mentionId + "\"" + } + editable.replace(editable.getSpanStart(mentionSpan), editable.getSpanEnd(mentionSpan), "@$mentionId") + } + + binding.fragmentMessageInputView.inputEditText?.setText("") + sendStopTypingMessage() + val replyMessageId = binding.fragmentMessageInputView + .findViewById(R.id.quotedChatMessageView)?.tag as Int? ?: 0 + + sendMessage( + editable, + replyMessageId, + sendWithoutNotification + ) + cancelReply() + } + } + + private fun sendMessage(message: CharSequence, replyTo: Int?, sendWithoutNotification: Boolean) { + chatActivity.messageInputViewModel.sendChatMessage( + chatActivity.conversationUser!!.getCredentials(), + ApiUtils.getUrlForChat( + chatActivity.chatApiVersion, + chatActivity.conversationUser!!.baseUrl!!, + chatActivity.roomToken + ), + message, + chatActivity.conversationUser!!.displayName ?: "", + replyTo ?: 0, + sendWithoutNotification + ) + } + + private fun showSendButtonMenu() { + val popupMenu = PopupMenu( + ContextThemeWrapper(requireContext(), R.style.ChatSendButtonMenu), + binding.fragmentMessageInputView.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 editMessageAPI(message: ChatMessage, editedMessageText: String) { + // FIXME Fix API checking with guests? + val apiVersion: Int = ApiUtils.getChatApiVersion(chatActivity.spreedCapabilities, intArrayOf(1)) + + chatActivity.messageInputViewModel.editChatMessage( + chatActivity.credentials!!, + ApiUtils.getUrlForChatMessage( + apiVersion, + chatActivity.conversationUser!!.baseUrl!!, + chatActivity.roomToken, + message.id + ), + editedMessageText + ) + } + + private fun setEditUI(message: ChatMessage) { + binding.fragmentEditView.editMessage.text = message.message + binding.fragmentMessageInputView.inputEditText.setText(message.message) + val end = binding.fragmentMessageInputView.inputEditText.text.length + binding.fragmentMessageInputView.inputEditText.setSelection(end) + binding.fragmentMessageInputView.messageSendButton.visibility = View.GONE + binding.fragmentMessageInputView.recordAudioButton.visibility = View.GONE + binding.fragmentMessageInputView.editMessageButton.visibility = View.VISIBLE + binding.fragmentEditView.editMessageView.visibility = View.VISIBLE + binding.fragmentMessageInputView.attachmentButton.visibility = View.GONE + } + + private fun clearEditUI() { + binding.fragmentMessageInputView.editMessageButton.visibility = View.GONE + binding.fragmentMessageInputView.inputEditText.setText("") + binding.fragmentEditView.editMessageView.visibility = View.GONE + binding.fragmentMessageInputView.attachmentButton.visibility = View.VISIBLE + chatActivity.messageInputViewModel.edit(null) + } + + private fun themeMessageInputView() { + binding.fragmentMessageInputView.button?.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) } + + binding.fragmentMessageInputView.findViewById(R.id.cancelReplyButton)?.setOnClickListener { + cancelReply() + } + + binding.fragmentMessageInputView.findViewById(R.id.cancelReplyButton)?.let { + viewThemeUtils.platform + .themeImageButton(it) + } + + binding.fragmentMessageInputView.findViewById(R.id.playPauseBtn)?.let { + viewThemeUtils.material.colorMaterialButtonText(it) + } + + binding.fragmentMessageInputView.findViewById(R.id.seekbar)?.let { + viewThemeUtils.platform.themeHorizontalSeekBar(it) + } + + binding.fragmentMessageInputView.findViewById(R.id.deleteVoiceRecording)?.let { + viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) + } + binding.fragmentMessageInputView.findViewById(R.id.sendVoiceRecording)?.let { + viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) + } + + binding.fragmentMessageInputView.findViewById(R.id.microphoneEnabledInfo)?.let { + viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) + } + + binding.fragmentMessageInputView.findViewById(R.id.voice_preview_container)?.let { + viewThemeUtils.talk.themeOutgoingMessageBubble(it, true, false) + } + + binding.fragmentMessageInputView.findViewById(R.id.micInputCloud)?.let { + viewThemeUtils.talk.themeMicInputCloud(it) + } + binding.fragmentMessageInputView.findViewById(R.id.editMessageButton)?.let { + viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) + } + binding.fragmentEditView.clearEdit.let { + viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) + } + } + + private fun cancelReply() { + val quote = binding.fragmentMessageInputView + .findViewById(R.id.quotedChatMessageView) + quote.visibility = View.GONE + quote.tag = null + binding.fragmentMessageInputView.findViewById(R.id.attachmentButton)?.visibility = View.VISIBLE + chatActivity.messageInputViewModel.reply(null) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt new file mode 100644 index 0000000000..a5dc768c68 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt @@ -0,0 +1,220 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Julius Linus juliuslinus1@gmail.com + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat + +import android.os.Bundle +import android.os.SystemClock +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import autodagger.AutoInjector +import com.nextcloud.android.common.ui.theme.utils.ColorRole +import com.nextcloud.talk.R +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication +import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager +import com.nextcloud.talk.databinding.FragmentMessageInputVoiceRecordingBinding +import com.nextcloud.talk.ui.theme.ViewThemeUtils +import javax.inject.Inject + +@AutoInjector(NextcloudTalkApplication::class) +class MessageInputVoiceRecordingFragment : Fragment() { + companion object { + val TAG: String = MessageInputVoiceRecordingFragment::class.java.simpleName + private const val SEEK_LIMIT = 98 + + @JvmStatic + fun newInstance() = MessageInputVoiceRecordingFragment() + } + + @Inject + lateinit var viewThemeUtils: ViewThemeUtils + + lateinit var binding: FragmentMessageInputVoiceRecordingBinding + private lateinit var chatActivity: ChatActivity + private var pause = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sharedApplication!!.componentApplication.inject(this) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + binding = FragmentMessageInputVoiceRecordingBinding.inflate(inflater) + chatActivity = (requireActivity() as ChatActivity) + themeVoiceRecordingView() + initVoiceRecordingView() + initObservers() + this.lifecycle.addObserver(chatActivity.messageInputViewModel) + return binding.root + } + + override fun onDestroyView() { + super.onDestroyView() + this.lifecycle.removeObserver(chatActivity.messageInputViewModel) + } + + private fun initObservers() { + chatActivity.messageInputViewModel.startMicInput(requireContext()) + chatActivity.messageInputViewModel.micInputAudioObserver.observe(viewLifecycleOwner) { + binding.micInputCloud.setRotationSpeed(it.first, it.second) + } + chatActivity.messageInputViewModel.mediaPlayerSeekbarObserver.observe(viewLifecycleOwner) { progress -> + if (progress >= SEEK_LIMIT) { + togglePausePlay() + binding.seekbar.progress = 0 + } else if (!pause) { + binding.seekbar.progress = progress + } + } + + chatActivity.messageInputViewModel.getAudioFocusChange.observe(viewLifecycleOwner) { state -> + when (state) { + AudioFocusRequestManager.ManagerState.AUDIO_FOCUS_CHANGE_LOSS -> { + if (chatActivity.messageInputViewModel.isVoicePreviewPlaying.value == true) { + chatActivity.messageInputViewModel.stopMediaPlayer() + } + } + AudioFocusRequestManager.ManagerState.AUDIO_FOCUS_CHANGE_LOSS_TRANSIENT -> { + if (chatActivity.messageInputViewModel.isVoicePreviewPlaying.value == true) { + chatActivity.messageInputViewModel.pauseMediaPlayer() + } + } + AudioFocusRequestManager.ManagerState.BROADCAST_RECEIVED -> { + if (chatActivity.messageInputViewModel.isVoicePreviewPlaying.value == true) { + chatActivity.messageInputViewModel.pauseMediaPlayer() + } + } + } + } + } + + private fun initVoiceRecordingView() { + binding.deleteVoiceRecording.setOnClickListener { + chatActivity.chatViewModel.stopAndDiscardAudioRecording() + clear() + } + + binding.sendVoiceRecording.setOnClickListener { + chatActivity.chatViewModel.stopAndSendAudioRecording( + chatActivity.roomToken, + chatActivity.currentConversation!!.displayName!!, + MessageInputFragment.VOICE_MESSAGE_META_DATA + ) + clear() + } + + binding.micInputCloud.setOnClickListener { + togglePreviewVisibility() + } + + binding.playPauseBtn.setOnClickListener { + togglePausePlay() + } + + binding.audioRecordDuration.base = chatActivity.messageInputViewModel.getRecordingTime.value ?: 0L + binding.audioRecordDuration.start() + + binding.seekbar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener { + override fun onProgressChanged(seekbar: SeekBar, progress: Int, fromUser: Boolean) { + if (fromUser) { + chatActivity.messageInputViewModel.seekMediaPlayerTo(progress) + } + } + + override fun onStartTrackingTouch(p0: SeekBar) { + pause = true + } + + override fun onStopTrackingTouch(p0: SeekBar) { + pause = false + } + }) + } + + private fun clear() { + chatActivity.chatViewModel.setVoiceRecordingLocked(false) + chatActivity.messageInputViewModel.stopMicInput() + chatActivity.chatViewModel.stopAudioRecording() + chatActivity.messageInputViewModel.stopMediaPlayer() + binding.audioRecordDuration.stop() + binding.audioRecordDuration.clearAnimation() + } + + private fun togglePreviewVisibility() { + val visibility = binding.voicePreviewContainer.visibility + binding.voicePreviewContainer.visibility = if (visibility == View.VISIBLE) { + chatActivity.messageInputViewModel.stopMediaPlayer() + binding.playPauseBtn.icon = ContextCompat.getDrawable( + requireContext(), + R.drawable.ic_baseline_play_arrow_voice_message_24 + ) + pause = true + chatActivity.messageInputViewModel.startMicInput(requireContext()) + chatActivity.chatViewModel.startAudioRecording(requireContext(), chatActivity.currentConversation!!) + binding.audioRecordDuration.visibility = View.VISIBLE + binding.audioRecordDuration.base = SystemClock.elapsedRealtime() + binding.audioRecordDuration.start() + View.GONE + } else { + pause = false + binding.seekbar.progress = 0 + chatActivity.messageInputViewModel.stopMicInput() + chatActivity.chatViewModel.stopAudioRecording() + binding.audioRecordDuration.visibility = View.GONE + binding.audioRecordDuration.stop() + View.VISIBLE + } + } + + private fun togglePausePlay() { + val path = chatActivity.chatViewModel.getCurrentVoiceRecordFile() + if (chatActivity.messageInputViewModel.isVoicePreviewPlaying.value == true) { + binding.playPauseBtn.icon = ContextCompat.getDrawable( + requireContext(), + R.drawable.ic_baseline_play_arrow_voice_message_24 + ) + chatActivity.messageInputViewModel.stopMediaPlayer() + } else { + binding.playPauseBtn.icon = ContextCompat.getDrawable( + requireContext(), + R.drawable.ic_baseline_pause_voice_message_24 + ) + chatActivity.messageInputViewModel.startMediaPlayer(path) + } + } + + private fun themeVoiceRecordingView() { + binding.playPauseBtn.let { + viewThemeUtils.material.colorMaterialButtonText(it) + } + + binding.seekbar.let { + viewThemeUtils.platform.themeHorizontalSeekBar(it) + } + + binding.deleteVoiceRecording.let { + viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) + } + binding.sendVoiceRecording.let { + viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) + } + + binding.voicePreviewContainer.let { + viewThemeUtils.talk.themeOutgoingMessageBubble(it, true, false) + } + + binding.micInputCloud.let { + viewThemeUtils.talk.themeMicInputCloud(it) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/AudioFocusRequestManager.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/AudioFocusRequestManager.kt new file mode 100644 index 0000000000..922475cb24 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/AudioFocusRequestManager.kt @@ -0,0 +1,112 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Julius Linus + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.io + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData + +/** + * Abstraction over the [AudioFocusManager](https://developer.android.com/reference/kotlin/android/media/AudioFocusRequest) + * class used to manage audio focus requests automatically + */ +class AudioFocusRequestManager(private val context: Context) { + companion object { + val TAG: String? = AudioFocusRequestManager::class.java.simpleName + } + + enum class ManagerState { + AUDIO_FOCUS_CHANGE_LOSS, + AUDIO_FOCUS_CHANGE_LOSS_TRANSIENT, + BROADCAST_RECEIVED + } + + private val _getManagerState: MutableLiveData = MutableLiveData() + val getManagerState: LiveData + get() = _getManagerState + + private var isPausedDueToBecomingNoisy = false + private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private val duration = AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE + private val audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener = + AudioManager.OnAudioFocusChangeListener { flag -> + when (flag) { + AudioManager.AUDIOFOCUS_LOSS -> { + isPausedDueToBecomingNoisy = false + _getManagerState.value = ManagerState.AUDIO_FOCUS_CHANGE_LOSS + } + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + isPausedDueToBecomingNoisy = false + _getManagerState.value = ManagerState.AUDIO_FOCUS_CHANGE_LOSS_TRANSIENT + } + } + } + private val noisyAudioStreamReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + isPausedDueToBecomingNoisy = true + _getManagerState.value = ManagerState.BROADCAST_RECEIVED + } + } + + @RequiresApi(Build.VERSION_CODES.O) + private val focusRequest = AudioFocusRequest.Builder(duration) + .setOnAudioFocusChangeListener(audioFocusChangeListener) + .build() + + /** + * Requests the OS for audio focus, before executing the callback on success + */ + fun audioFocusRequest(shouldRequestFocus: Boolean, onGranted: () -> Unit) { + if (isPausedDueToBecomingNoisy) { + onGranted() + return + } + + val isGranted: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (shouldRequestFocus) { + audioManager.requestAudioFocus(focusRequest) + } else { + audioManager.abandonAudioFocusRequest(focusRequest) + } + } else { + @Deprecated("This method was deprecated in API level 26.") + if (shouldRequestFocus) { + audioManager.requestAudioFocus(audioFocusChangeListener, AudioManager.STREAM_MUSIC, duration) + } else { + audioManager.abandonAudioFocus(audioFocusChangeListener) + } + } + if (isGranted == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + onGranted() + handleBecomingNoisyBroadcast(shouldRequestFocus) + } + } + + private fun handleBecomingNoisyBroadcast(register: Boolean) { + try { + if (register) { + context.registerReceiver( + noisyAudioStreamReceiver, + IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + ) + } else { + context.unregisterReceiver(noisyAudioStreamReceiver) + } + } catch (e: IllegalArgumentException) { + e.printStackTrace() + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/AudioRecorderManager.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/AudioRecorderManager.kt new file mode 100644 index 0000000000..2a91e52c66 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/AudioRecorderManager.kt @@ -0,0 +1,143 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Julius Linus + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.io + +import android.Manifest +import android.content.Context +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.nextcloud.talk.ui.MicInputCloud +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.abs +import kotlin.math.log10 + +/** + * Abstraction over the [AudioRecord](https://developer.android.com/reference/android/media/AudioRecord) class used + * to manage the AudioRecord instance and the asynchronous updating of the MicInputCloud. Allows access to the raw + * bytes recorded from hardware. + */ +class AudioRecorderManager : LifecycleAwareManager { + + companion object { + val TAG: String = AudioRecorderManager::class.java.simpleName + private const val SAMPLE_RATE = 8000 + private const val AUDIO_MAX = 40 + private const val AUDIO_MIN = 20 + private const val AUDIO_INTERVAL = 50L + } + private val _getAudioValues: MutableLiveData> = MutableLiveData() + val getAudioValues: LiveData> + get() = _getAudioValues + + private var scope = MainScope() + private var loop = false + private var audioRecorder: AudioRecord? = null + private val bufferSize = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) + + /** + * Initializes and starts the AudioRecorder. Posts updates to the callback every 50 ms. + */ + fun start(context: Context) { + if (audioRecorder == null || audioRecorder!!.state == AudioRecord.STATE_UNINITIALIZED) { + initAudioRecorder(context) + } + Log.d(TAG, "AudioRecorder started") + audioRecorder!!.startRecording() + loop = true + scope = MainScope().apply { + launch { + Log.d(TAG, "MicInputObserver started") + micInputObserver() + } + } + } + + /** + * Stops and destroys the AudioRecorder. Updates cancelled. + */ + fun stop() { + if (audioRecorder == null || audioRecorder!!.state == AudioRecord.STATE_UNINITIALIZED) { + Log.e(TAG, "Stopped AudioRecord on invalid state ") + return + } + Log.d(TAG, "AudioRecorder stopped") + loop = false + audioRecorder!!.stop() + audioRecorder!!.release() + audioRecorder = null + } + + private suspend fun micInputObserver() { + withContext(Dispatchers.IO) { + while (true) { + if (!loop) { + return@withContext + } + val byteArr = ByteArray(bufferSize / 2) + audioRecorder!!.read(byteArr, 0, byteArr.size) + val x = abs(byteArr[0].toFloat()) + val logX = log10(x) + if (x > AUDIO_MAX) { + _getAudioValues.postValue(Pair(logX, MicInputCloud.MAXIMUM_RADIUS)) + } else if (x > AUDIO_MIN) { + _getAudioValues.postValue(Pair(logX, MicInputCloud.EXTENDED_RADIUS)) + } else { + _getAudioValues.postValue(Pair(1f, MicInputCloud.DEFAULT_RADIUS)) + } + + delay(AUDIO_INTERVAL) + } + } + } + + private fun initAudioRecorder(context: Context) { + val permissionCheck = ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO + ) + + if (permissionCheck == PermissionChecker.PERMISSION_GRANTED) { + Log.d(TAG, "AudioRecorder init") + audioRecorder = AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize + ) + } + } + + override fun handleOnPause() { + // unused atm + } + + override fun handleOnResume() { + // unused atm + } + + override fun handleOnStop() { + scope.cancel() + stop() + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/LifecycleAwareManager.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/LifecycleAwareManager.kt new file mode 100644 index 0000000000..78436050bb --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/LifecycleAwareManager.kt @@ -0,0 +1,32 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Julius Linus + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.io + +/** + * Interface used by manager classes in the data layer. Enforces that every Manager handles the lifecycle events + * observed by the view model. + */ +interface LifecycleAwareManager { + /** + * See [onPause](https://developer.android.com/guide/components/activities/activity-lifecycle#onpause) + * for more details. + */ + fun handleOnPause() + + /** + * See [onResume](https://developer.android.com/guide/components/activities/activity-lifecycle#onresume) + * for more details. + */ + fun handleOnResume() + + /** + * See [onStop](https://developer.android.com/guide/components/activities/activity-lifecycle#onstop) + * for more details. + */ + fun handleOnStop() +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt new file mode 100644 index 0000000000..e610d2358f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt @@ -0,0 +1,138 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Julius Linus + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.io + +import android.media.MediaPlayer +import android.util.Log +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.nextcloud.talk.chat.ChatActivity +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** + * Abstraction over the [MediaPlayer](https://developer.android.com/reference/android/media/MediaPlayer) class used + * to manage the MediaPlayer instance. + */ +class MediaPlayerManager : LifecycleAwareManager { + companion object { + val TAG: String = MediaPlayerManager::class.java.simpleName + private const val SEEKBAR_UPDATE_DELAY = 15L + const val DIVIDER = 100f + } + + private var mediaPlayer: MediaPlayer? = null + private var mediaPlayerPosition: Int = 0 + private var loop = false + private var scope = MainScope() + var mediaPlayerDuration: Int = 0 + private val _mediaPlayerSeekBarPosition: MutableLiveData = MutableLiveData() + val mediaPlayerSeekBarPosition: LiveData + get() = _mediaPlayerSeekBarPosition + + /** + * Starts playing audio from the given path, initializes or resumes if the player is already created. + */ + fun start(path: String) { + if (mediaPlayer == null || !scope.isActive) { + init(path) + } else { + mediaPlayer!!.start() + loop = true + scope.launch { seekbarUpdateObserver() } + } + } + + /** + * Stop and destroys the player. + */ + fun stop() { + if (mediaPlayer != null) { + Log.d(TAG, "media player destroyed") + loop = false + mediaPlayer!!.stop() + mediaPlayer!!.release() + mediaPlayer = null + } + } + + /** + * Pauses the player. + */ + fun pause() { + if (mediaPlayer != null) { + Log.d(TAG, "media player paused") + mediaPlayer!!.pause() + } + } + + /** + * Seeks the player to the given position, saves position for resynchronization. + */ + fun seekTo(progress: Int) { + if (mediaPlayer != null) { + val pos = mediaPlayer!!.duration * (progress / DIVIDER) + mediaPlayer!!.seekTo(pos.toInt()) + mediaPlayerPosition = pos.toInt() + } + } + + private suspend fun seekbarUpdateObserver() { + withContext(Dispatchers.IO) { + while (true) { + if (!loop) { + return@withContext + } + if (mediaPlayer != null && mediaPlayer!!.isPlaying) { + val pos = mediaPlayer!!.currentPosition + val progress = (pos.toFloat() / mediaPlayerDuration) * DIVIDER + _mediaPlayerSeekBarPosition.postValue(progress.toInt()) + } + + delay(SEEKBAR_UPDATE_DELAY) + } + } + } + + @Suppress("Detekt.TooGenericExceptionCaught") + private fun init(path: String) { + try { + mediaPlayer = MediaPlayer().apply { + setDataSource(path) + prepareAsync() + setOnPreparedListener { + mediaPlayerDuration = it.duration + start() + loop = true + scope = MainScope() + scope.launch { seekbarUpdateObserver() } + } + } + } catch (e: Exception) { + Log.e(ChatActivity.TAG, "failed to initialize mediaPlayer", e) + } + } + + override fun handleOnPause() { + // unused atm + } + + override fun handleOnResume() { + // unused atm + } + + override fun handleOnStop() { + stop() + scope.cancel() + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaRecorderManager.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaRecorderManager.kt new file mode 100644 index 0000000000..6e2bae64b0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/MediaRecorderManager.kt @@ -0,0 +1,165 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Julius Linus + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.data.io + +import android.annotation.SuppressLint +import android.content.Context +import android.media.MediaRecorder +import android.util.Log +import com.nextcloud.talk.R +import com.nextcloud.talk.models.domain.ConversationModel +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date + +/** + * Abstraction over the [MediaRecorder](https://developer.android.com/reference/android/media/MediaRecorder) class + * used to manage the MediaRecorder instance and it's state changes. Google doesn't provide a way of accessing state + * directly, so this handles the changes without exposing the user to it. + */ +class MediaRecorderManager : LifecycleAwareManager { + + companion object { + val TAG: String = MediaRecorderManager::class.java.simpleName + 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 VOICE_MESSAGE_FILE_SUFFIX = ".mp3" + } + + var currentVoiceRecordFile: String = "" + + enum class MediaRecorderState { + INITIAL, + INITIALIZED, + CONFIGURED, + PREPARED, + RECORDING, + RELEASED, + ERROR + } + private var _mediaRecorderState: MediaRecorderState = MediaRecorderState.INITIAL + val mediaRecorderState: MediaRecorderState + get() = _mediaRecorderState + private var recorder: MediaRecorder? = null + + /** + * Initializes and starts the MediaRecorder + */ + fun start(context: Context, currentConversation: ConversationModel) { + if (_mediaRecorderState == MediaRecorderState.ERROR || + _mediaRecorderState == MediaRecorderState.RELEASED + ) { + _mediaRecorderState = MediaRecorderState.INITIAL + } + + if (_mediaRecorderState == MediaRecorderState.INITIAL) { + setVoiceRecordFileName(context, currentConversation) + initAndStartRecorder() + } else { + Log.e(TAG, "Started MediaRecorder with invalid state ${_mediaRecorderState.name}") + } + } + + /** + * Stops and destroys the MediaRecorder + */ + fun stop() { + if (_mediaRecorderState != MediaRecorderState.RELEASED) { + stopAndDestroyRecorder() + } else { + Log.e(TAG, "Stopped MediaRecorder with invalid state ${_mediaRecorderState.name}") + } + } + + private fun initAndStartRecorder() { + recorder = MediaRecorder().apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + _mediaRecorderState = MediaRecorderState.INITIALIZED + + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + _mediaRecorderState = MediaRecorderState.CONFIGURED + + setOutputFile(currentVoiceRecordFile) + 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") + } + } + } + + @Suppress("TooGenericExceptionCaught") + private fun stopAndDestroyRecorder() { + 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") + } + } + } + } + recorder = null + } + + @SuppressLint("SimpleDateFormat") + private fun setVoiceRecordFileName(context: Context, currentConversation: ConversationModel) { + 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" + } + + override fun handleOnPause() { + // unused atm + } + + override fun handleOnResume() { + // unused atm + } + + override fun handleOnStop() { + stop() + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index c681b07745..12c55b37ec 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -6,6 +6,8 @@ */ package com.nextcloud.talk.chat.viewmodels +import android.content.Context +import android.net.Uri import android.util.Log import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -13,7 +15,10 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.nextcloud.talk.chat.data.ChatRepository +import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager +import com.nextcloud.talk.chat.data.io.MediaRecorderManager import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.ReactionAddedModel import com.nextcloud.talk.models.domain.ReactionDeletedModel @@ -31,34 +36,58 @@ import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.disposables.Disposable import io.reactivex.schedulers.Schedulers import retrofit2.Response +import java.io.File import javax.inject.Inject @Suppress("TooManyFunctions", "LongParameterList") class ChatViewModel @Inject constructor( private val chatRepository: ChatRepository, - private val reactionsRepository: ReactionsRepository -) : ViewModel() { + private val reactionsRepository: ReactionsRepository, + private val mediaRecorderManager: MediaRecorderManager, + private val audioFocusRequestManager: AudioFocusRequestManager +) : ViewModel(), DefaultLifecycleObserver { + + enum class LifeCycleFlag { + PAUSED, + RESUMED, + STOPPED + } + lateinit var currentLifeCycleFlag: LifeCycleFlag + val disposableSet = mutableSetOf() - object LifeCycleObserver : DefaultLifecycleObserver { - enum class LifeCycleFlag { - PAUSED, - RESUMED - } - lateinit var currentLifeCycleFlag: LifeCycleFlag - public val disposableSet = mutableSetOf() + override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + currentLifeCycleFlag = LifeCycleFlag.RESUMED + mediaRecorderManager.handleOnResume() + } - override fun onResume(owner: LifecycleOwner) { - super.onResume(owner) - currentLifeCycleFlag = LifeCycleFlag.RESUMED - } + override fun onPause(owner: LifecycleOwner) { + super.onPause(owner) + currentLifeCycleFlag = LifeCycleFlag.PAUSED + disposableSet.forEach { disposable -> disposable.dispose() } + disposableSet.clear() + mediaRecorderManager.handleOnPause() + } - override fun onPause(owner: LifecycleOwner) { - super.onPause(owner) - currentLifeCycleFlag = LifeCycleFlag.PAUSED - disposableSet.forEach { disposable -> disposable.dispose() } - disposableSet.clear() - } + override fun onStop(owner: LifecycleOwner) { + super.onStop(owner) + currentLifeCycleFlag = LifeCycleFlag.STOPPED + mediaRecorderManager.handleOnStop() } + val getAudioFocusChange: LiveData + get() = audioFocusRequestManager.getManagerState + + private val _recordTouchObserver: MutableLiveData = MutableLiveData() + val recordTouchObserver: LiveData + get() = _recordTouchObserver + + private val _getVoiceRecordingInProgress: MutableLiveData = MutableLiveData() + val getVoiceRecordingInProgress: LiveData + get() = _getVoiceRecordingInProgress + + private val _getVoiceRecordingLocked: MutableLiveData = MutableLiveData() + val getVoiceRecordingLocked: LiveData + get() = _getVoiceRecordingLocked private val _getFieldMapForChat: MutableLiveData> = MutableLiveData() val getFieldMapForChat: LiveData> @@ -70,10 +99,6 @@ class ChatViewModel @Inject constructor( private val _getReminderExistState: MutableLiveData = MutableLiveData(GetReminderStartState) - var isPausedDueToBecomingNoisy = false - var receiverRegistered = false - var receiverUnregistered = false - val getReminderExistState: LiveData get() = _getReminderExistState @@ -94,7 +119,8 @@ class ChatViewModel @Inject constructor( object GetCapabilitiesStartState : ViewState object GetCapabilitiesErrorState : ViewState - open class GetCapabilitiesSuccessState(val spreedCapabilities: SpreedCapability) : ViewState + open class GetCapabilitiesInitialLoadState(val spreedCapabilities: SpreedCapability) : ViewState + open class GetCapabilitiesUpdateState(val spreedCapabilities: SpreedCapability) : ViewState private val _getCapabilitiesViewState: MutableLiveData = MutableLiveData(GetCapabilitiesStartState) val getCapabilitiesViewState: LiveData @@ -156,14 +182,6 @@ class ChatViewModel @Inject constructor( val reactionDeletedViewState: LiveData get() = _reactionDeletedViewState - object EditMessageStartState : ViewState - object EditMessageErrorState : ViewState - class EditMessageSuccessState(val messageEdited: ChatOverallSingleMessage) : ViewState - - private val _editMessageViewState: MutableLiveData = MutableLiveData(EditMessageStartState) - val editMessageViewState: LiveData - get() = _editMessageViewState - fun refreshChatParams(pullChatMessagesFieldMap: HashMap, overrideRefresh: Boolean = false) { if (pullChatMessagesFieldMap != _getFieldMapForChat.value || overrideRefresh) { _getFieldMapForChat.postValue(pullChatMessagesFieldMap) @@ -180,21 +198,30 @@ class ChatViewModel @Inject constructor( } fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) { - _getCapabilitiesViewState.value = GetCapabilitiesStartState - + Log.d(TAG, "Remote server ${conversationModel.remoteServer}") if (conversationModel.remoteServer.isNullOrEmpty()) { - _getCapabilitiesViewState.value = GetCapabilitiesSuccessState(user.capabilities!!.spreedCapability!!) + if (_getCapabilitiesViewState.value == GetCapabilitiesStartState) { + _getCapabilitiesViewState.value = GetCapabilitiesInitialLoadState( + user.capabilities!!.spreedCapability!! + ) + } else { + _getCapabilitiesViewState.value = GetCapabilitiesUpdateState(user.capabilities!!.spreedCapability!!) + } } else { chatRepository.getCapabilities(user, token) .subscribeOn(Schedulers.io()) ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onNext(spreedCapabilities: SpreedCapability) { - _getCapabilitiesViewState.value = GetCapabilitiesSuccessState(spreedCapabilities) + if (_getCapabilitiesViewState.value == GetCapabilitiesStartState) { + _getCapabilitiesViewState.value = GetCapabilitiesInitialLoadState(spreedCapabilities) + } else { + _getCapabilitiesViewState.value = GetCapabilitiesUpdateState(spreedCapabilities) + } } override fun onError(e: Throwable) { @@ -238,7 +265,7 @@ class ChatViewModel @Inject constructor( ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onNext(genericOverall: GenericOverall) { @@ -262,7 +289,7 @@ class ChatViewModel @Inject constructor( ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onError(e: Throwable) { @@ -275,6 +302,8 @@ class ChatViewModel @Inject constructor( override fun onNext(t: GenericOverall) { _leaveRoomViewState.value = LeaveRoomSuccessState(funToCallWhenLeaveSuccessful) + _getCapabilitiesViewState.value = GetCapabilitiesStartState + _getRoomViewState.value = GetRoomStartState } }) } @@ -285,7 +314,7 @@ class ChatViewModel @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) .subscribe(object : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onError(e: Throwable) { @@ -322,7 +351,7 @@ class ChatViewModel @Inject constructor( ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onError(e: Throwable) { @@ -342,12 +371,12 @@ class ChatViewModel @Inject constructor( fun pullChatMessages(credentials: String, url: String) { chatRepository.pullChatMessages(credentials, url, _getFieldMapForChat.value!!) .subscribeOn(Schedulers.io()) - .takeUntil { (LifeCycleObserver.currentLifeCycleFlag == LifeCycleObserver.LifeCycleFlag.PAUSED) } + .takeUntil { (currentLifeCycleFlag == LifeCycleFlag.PAUSED) } ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer> { override fun onSubscribe(d: Disposable) { Log.d(TAG, "pullChatMessages - pullChatMessages SUBSCRIBE") - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onError(e: Throwable) { @@ -373,7 +402,7 @@ class ChatViewModel @Inject constructor( ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onError(e: Throwable) { @@ -402,7 +431,7 @@ class ChatViewModel @Inject constructor( .observeOn(AndroidSchedulers.mainThread()) .subscribe(object : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onError(e: Throwable) { @@ -425,7 +454,7 @@ class ChatViewModel @Inject constructor( ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onNext(genericOverall: GenericOverall) { @@ -454,7 +483,7 @@ class ChatViewModel @Inject constructor( ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onNext(genericOverall: GenericOverall) { @@ -477,7 +506,7 @@ class ChatViewModel @Inject constructor( ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onError(e: Throwable) { @@ -502,7 +531,7 @@ class ChatViewModel @Inject constructor( ?.observeOn(AndroidSchedulers.mainThread()) ?.subscribe(object : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onError(e: Throwable) { @@ -521,28 +550,69 @@ class ChatViewModel @Inject constructor( }) } - fun editChatMessage(credentials: String, url: String, text: String) { - chatRepository.editChatMessage(credentials, url, text) - .subscribeOn(Schedulers.io()) - ?.observeOn(AndroidSchedulers.mainThread()) - ?.subscribe(object : Observer { - override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) - } + fun startAudioRecording(context: Context, currentConversation: ConversationModel) { + audioFocusRequestManager.audioFocusRequest(true) { + Log.d(TAG, "Recording Started") + mediaRecorderManager.start(context, currentConversation) + _getVoiceRecordingInProgress.postValue(true) + } + } - override fun onError(e: Throwable) { - Log.e(TAG, "failed to edit message", e) - _editMessageViewState.value = EditMessageErrorState - } + fun stopAudioRecording() { + audioFocusRequestManager.audioFocusRequest(false) { + mediaRecorderManager.stop() + _getVoiceRecordingInProgress.postValue(false) + Log.d(TAG, "Recording stopped") + } + } - override fun onComplete() { - // unused atm - } + fun stopAndSendAudioRecording(room: String, displayName: String, metaData: String) { + stopAudioRecording() - override fun onNext(messageEdited: ChatOverallSingleMessage) { - _editMessageViewState.value = EditMessageSuccessState(messageEdited) - } - }) + if (mediaRecorderManager.mediaRecorderState != MediaRecorderManager.MediaRecorderState.ERROR) { + val uri = Uri.fromFile(File(mediaRecorderManager.currentVoiceRecordFile)) + Log.d(TAG, "File uploaded") + uploadFile(uri.toString(), room, displayName, metaData) + } + } + fun stopAndDiscardAudioRecording() { + stopAudioRecording() + Log.d(TAG, "File discarded") + val cachedFile = File(mediaRecorderManager.currentVoiceRecordFile) + cachedFile.delete() + } + + fun getCurrentVoiceRecordFile(): String { + return mediaRecorderManager.currentVoiceRecordFile + } + + fun uploadFile(fileUri: String, room: String, displayName: String, metaData: String) { + try { + require(fileUri.isNotEmpty()) + UploadAndShareFilesWorker.upload( + fileUri, + room, + displayName, + metaData + ) + } catch (e: IllegalArgumentException) { + Log.e(javaClass.simpleName, "Something went wrong when trying to upload file", e) + } + } + + fun postToRecordTouchObserver(float: Float) { + _recordTouchObserver.postValue(float) + } + + fun setVoiceRecordingLocked(boolean: Boolean) { + _getVoiceRecordingLocked.postValue(boolean) + } + + // Made this so that the MediaPlayer in ChatActivity can be focused. Eventually the player logic should be moved + // to the MediaPlayerManager class, so the audio focus logic can be handled in ChatViewModel, as it's done in + // the MessageInputViewModel + fun audioRequest(request: Boolean, callback: () -> Unit) { + audioFocusRequestManager.audioFocusRequest(request, callback) } inner class GetRoomObserver : Observer { @@ -566,7 +636,7 @@ class ChatViewModel @Inject constructor( inner class JoinRoomObserver : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onNext(conversationModel: ConversationModel) { @@ -585,7 +655,7 @@ class ChatViewModel @Inject constructor( inner class SetReminderObserver : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onNext(reminder: Reminder) { @@ -603,7 +673,7 @@ class ChatViewModel @Inject constructor( inner class GetReminderObserver : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onNext(reminder: Reminder) { @@ -622,7 +692,7 @@ class ChatViewModel @Inject constructor( inner class CheckForNoteToSelfObserver : Observer { override fun onSubscribe(d: Disposable) { - LifeCycleObserver.disposableSet.add(d) + disposableSet.add(d) } override fun onNext(roomsOverall: RoomsOverall) { diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt new file mode 100644 index 0000000000..509268ac87 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt @@ -0,0 +1,219 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Julius Linus juliuslinus1@gmail.com + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat.viewmodels + +import android.content.Context +import android.util.Log +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.nextcloud.talk.chat.data.ChatRepository +import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager +import com.nextcloud.talk.chat.data.io.AudioRecorderManager +import com.nextcloud.talk.chat.data.io.MediaPlayerManager +import com.nextcloud.talk.models.json.chat.ChatOverallSingleMessage +import com.nextcloud.talk.models.json.generic.GenericOverall +import com.stfalcon.chatkit.commons.models.IMessage +import io.reactivex.Observer +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.Disposable +import io.reactivex.schedulers.Schedulers +import javax.inject.Inject + +class MessageInputViewModel @Inject constructor( + private val chatRepository: ChatRepository, + private val audioRecorderManager: AudioRecorderManager, + private val mediaPlayerManager: MediaPlayerManager, + private val audioFocusRequestManager: AudioFocusRequestManager +) : ViewModel(), DefaultLifecycleObserver { + enum class LifeCycleFlag { + PAUSED, + RESUMED, + STOPPED + } + lateinit var currentLifeCycleFlag: LifeCycleFlag + val disposableSet = mutableSetOf() + + override fun onResume(owner: LifecycleOwner) { + super.onResume(owner) + currentLifeCycleFlag = LifeCycleFlag.RESUMED + audioRecorderManager.handleOnResume() + mediaPlayerManager.handleOnResume() + } + + override fun onPause(owner: LifecycleOwner) { + super.onPause(owner) + currentLifeCycleFlag = LifeCycleFlag.PAUSED + disposableSet.forEach { disposable -> disposable.dispose() } + disposableSet.clear() + audioRecorderManager.handleOnPause() + mediaPlayerManager.handleOnPause() + } + + override fun onStop(owner: LifecycleOwner) { + super.onStop(owner) + currentLifeCycleFlag = LifeCycleFlag.STOPPED + audioRecorderManager.handleOnStop() + mediaPlayerManager.handleOnStop() + } + + companion object { + private val TAG = MessageInputViewModel::class.java.simpleName + } + val getAudioFocusChange: LiveData + get() = audioFocusRequestManager.getManagerState + + private val _getRecordingTime: MutableLiveData = MutableLiveData(0L) + val getRecordingTime: LiveData + get() = _getRecordingTime + + val micInputAudioObserver: LiveData> + get() = audioRecorderManager.getAudioValues + + val mediaPlayerSeekbarObserver: LiveData + get() = mediaPlayerManager.mediaPlayerSeekBarPosition + + private val _getEditChatMessage: MutableLiveData = MutableLiveData() + val getEditChatMessage: LiveData + get() = _getEditChatMessage + + private val _getReplyChatMessage: MutableLiveData = MutableLiveData() + val getReplyChatMessage: LiveData + get() = _getReplyChatMessage + + sealed interface ViewState + object SendChatMessageStartState : ViewState + class SendChatMessageSuccessState(val message: CharSequence) : ViewState + class SendChatMessageErrorState(val e: Throwable, val message: CharSequence) : ViewState + private val _sendChatMessageViewState: MutableLiveData = MutableLiveData(SendChatMessageStartState) + val sendChatMessageViewState: LiveData + get() = _sendChatMessageViewState + object EditMessageStartState : ViewState + object EditMessageErrorState : ViewState + class EditMessageSuccessState(val messageEdited: ChatOverallSingleMessage) : ViewState + + private val _editMessageViewState: MutableLiveData = MutableLiveData() + val editMessageViewState: LiveData + get() = _editMessageViewState + + private val _isVoicePreviewPlaying: MutableLiveData = MutableLiveData(false) + val isVoicePreviewPlaying: LiveData + get() = _isVoicePreviewPlaying + + @Suppress("LongParameterList") + fun sendChatMessage( + credentials: String, + url: String, + message: CharSequence, + displayName: String, + replyTo: Int, + sendWithoutNotification: Boolean + ) { + chatRepository.sendChatMessage( + credentials, + url, + message, + displayName, + replyTo, + sendWithoutNotification + ).subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + disposableSet.add(d) + } + + override fun onError(e: Throwable) { + _sendChatMessageViewState.value = SendChatMessageErrorState(e, message) + } + + override fun onComplete() { + // unused atm + } + + override fun onNext(t: GenericOverall) { + _sendChatMessageViewState.value = SendChatMessageSuccessState(message) + } + }) + } + + fun editChatMessage(credentials: String, url: String, text: String) { + chatRepository.editChatMessage(credentials, url, text) + .subscribeOn(Schedulers.io()) + ?.observeOn(AndroidSchedulers.mainThread()) + ?.subscribe(object : Observer { + override fun onSubscribe(d: Disposable) { + disposableSet.add(d) + } + + override fun onError(e: Throwable) { + Log.e(TAG, "failed to edit message", e) + _editMessageViewState.value = EditMessageErrorState + } + + override fun onComplete() { + // unused atm + } + + override fun onNext(messageEdited: ChatOverallSingleMessage) { + _editMessageViewState.value = EditMessageSuccessState(messageEdited) + } + }) + } + + fun reply(message: IMessage?) { + _getReplyChatMessage.postValue(message) + } + + fun edit(message: IMessage?) { + _getEditChatMessage.postValue(message) + } + + fun startMicInput(context: Context) { + audioFocusRequestManager.audioFocusRequest(true) { + audioRecorderManager.start(context) + } + } + + fun stopMicInput() { + audioFocusRequestManager.audioFocusRequest(false) { + audioRecorderManager.stop() + } + } + + fun startMediaPlayer(path: String) { + audioFocusRequestManager.audioFocusRequest(true) { + mediaPlayerManager.start(path) + _isVoicePreviewPlaying.postValue(true) + } + } + + fun pauseMediaPlayer() { + audioFocusRequestManager.audioFocusRequest(false) { + mediaPlayerManager.pause() + _isVoicePreviewPlaying.postValue(false) + } + } + + fun stopMediaPlayer() { + audioFocusRequestManager.audioFocusRequest(false) { + mediaPlayerManager.stop() + _isVoicePreviewPlaying.postValue(false) + } + } + + fun seekMediaPlayerTo(progress: Int) { + mediaPlayerManager.seekTo(progress) + } + + fun setRecordingTime(time: Long) { + _getRecordingTime.postValue(time) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt new file mode 100644 index 0000000000..3ce6cdf19f --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt @@ -0,0 +1,40 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2024 Julius Linus + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.dagger.modules + +import android.content.Context +import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager +import com.nextcloud.talk.chat.data.io.AudioRecorderManager +import com.nextcloud.talk.chat.data.io.MediaPlayerManager +import com.nextcloud.talk.chat.data.io.MediaRecorderManager +import dagger.Module +import dagger.Provides + +@Module +class ManagerModule { + + @Provides + fun provideMediaRecorderManager(): MediaRecorderManager { + return MediaRecorderManager() + } + + @Provides + fun provideAudioRecorderManager(): AudioRecorderManager { + return AudioRecorderManager() + } + + @Provides + fun provideMediaPlayerManager(): MediaPlayerManager { + return MediaPlayerManager() + } + + @Provides + fun provideAudioFocusManager(context: Context): AudioFocusRequestManager { + return AudioFocusRequestManager(context) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt index 55cf99769e..e800e90afb 100644 --- a/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt +++ b/app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt @@ -10,6 +10,7 @@ package com.nextcloud.talk.dagger.modules import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.nextcloud.talk.chat.viewmodels.ChatViewModel +import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel import com.nextcloud.talk.conversation.viewmodel.ConversationViewModel import com.nextcloud.talk.conversation.viewmodel.RenameConversationViewModel import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel @@ -118,6 +119,13 @@ abstract class ViewModelModule { @ViewModelKey(ChatViewModel::class) abstract fun chatViewModel(viewModel: ChatViewModel): ViewModel + @Binds + @IntoMap + @ViewModelKey(MessageInputViewModel::class) + abstract fun messageInputViewModel(viewModel: MessageInputViewModel): ViewModel + + // TODO I had a merge conflict here that went weird. choose their version + @Binds @IntoMap @ViewModelKey(ConversationInfoViewModel::class) diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt index b13e3bebf5..7fddcf2e94 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt @@ -335,8 +335,7 @@ class MessageActionsDialog( private fun initMenuEditMessage(visible: Boolean) { dialogMessageActionsBinding.menuEditMessage.setOnClickListener { - chatActivity.editMessage(message) - Log.d("EDIT MESSAGE", "$message") + chatActivity.messageInputViewModel.edit(message) dismiss() } @@ -357,7 +356,7 @@ class MessageActionsDialog( private fun initMenuReplyToMessage(visible: Boolean) { if (visible) { dialogMessageActionsBinding.menuReplyToMessage.setOnClickListener { - chatActivity.replyToMessage(message) + chatActivity.messageInputViewModel.reply(message) dismiss() } } diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index f9efc89c16..554ffbddb4 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -233,49 +233,16 @@ android:maxLines="2" android:textColor="@color/low_emphasis_text" tools:ignore="Overdraw" - tools:text="Marcel is typing"> + tools:text="Marcel is typing"/> - - - - - - - + android:padding="0dp" + /> \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_message_input.xml b/app/src/main/res/layout/fragment_message_input.xml new file mode 100644 index 0000000000..f854414bdf --- /dev/null +++ b/app/src/main/res/layout/fragment_message_input.xml @@ -0,0 +1,47 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_message_input_voice_recording.xml b/app/src/main/res/layout/fragment_message_input_voice_recording.xml new file mode 100644 index 0000000000..ee5e1b87ec --- /dev/null +++ b/app/src/main/res/layout/fragment_message_input_voice_recording.xml @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b9b33e95da..eefd4fc690 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -15,7 +15,7 @@ How to translate with transifex: --> -