Просмотр исходного кода

Message Input Refactoring

- Added io folder for Abstracting away background work
- AudioFocusRequestManager
- MediaPlayerManager
- MediaRecorderManager
- AudioRecorderManager

Included new View Models + Fragments to separate concerns

- MessageInputFragment
- MessageInputVoiceRecordingFragment

Signed-off-by: rapterjet2004 <juliuslinus1@gmail.com>
Julius Linus 11 месяцев назад
Родитель
Сommit
6a01ebf630
19 измененных файлов с 2290 добавлено и 921 удалено
  1. 3 1
      app/src/main/java/com/nextcloud/talk/application/NextcloudTalkApplication.kt
  2. 79 805
      app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt
  3. 821 0
      app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt
  4. 220 0
      app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt
  5. 112 0
      app/src/main/java/com/nextcloud/talk/chat/data/io/AudioFocusRequestManager.kt
  6. 143 0
      app/src/main/java/com/nextcloud/talk/chat/data/io/AudioRecorderManager.kt
  7. 32 0
      app/src/main/java/com/nextcloud/talk/chat/data/io/LifecycleAwareManager.kt
  8. 138 0
      app/src/main/java/com/nextcloud/talk/chat/data/io/MediaPlayerManager.kt
  9. 165 0
      app/src/main/java/com/nextcloud/talk/chat/data/io/MediaRecorderManager.kt
  10. 142 72
      app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt
  11. 219 0
      app/src/main/java/com/nextcloud/talk/chat/viewmodels/MessageInputViewModel.kt
  12. 40 0
      app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt
  13. 8 0
      app/src/main/java/com/nextcloud/talk/dagger/modules/ViewModelModule.kt
  14. 2 3
      app/src/main/java/com/nextcloud/talk/ui/dialog/MessageActionsDialog.kt
  15. 5 38
      app/src/main/res/layout/activity_chat.xml
  16. 47 0
      app/src/main/res/layout/fragment_message_input.xml
  17. 112 0
      app/src/main/res/layout/fragment_message_input_voice_recording.xml
  18. 1 1
      app/src/main/res/values/strings.xml
  19. 1 1
      scripts/analysis/lint-results.txt

+ 3 - 1
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

Разница между файлами не показана из-за своего большого размера
+ 79 - 805
app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt


+ 821 - 0
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<InputFilter>(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>(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<Mention>(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<ImageButton>(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<ImageButton>(R.id.attachmentButton)?.visibility =
+                View.GONE
+            view.findViewById<ImageButton>(R.id.cancelReplyButton)?.visibility =
+                View.VISIBLE
+
+            val quotedMessage = view.findViewById<EmojiTextView>(R.id.quotedMessage)
+
+            quotedMessage?.maxLines = 2
+            quotedMessage?.ellipsize = TextUtils.TruncateAt.END
+            quotedMessage?.text = it.text
+            view.findViewById<EmojiTextView>(R.id.quotedMessageAuthor)?.text =
+                it.actorDisplayName ?: requireContext().getText(R.string.nc_nick_guest)
+
+            chatActivity.conversationUser?.let {
+                val quotedMessageImage = view.findViewById<ImageView>(R.id.quotedMessageImage)
+                chatMessage.imageUrl?.let { previewImageUrl ->
+                    quotedMessageImage?.visibility = View.VISIBLE
+
+                    val px = TypedValue.applyDimension(
+                        TypedValue.COMPLEX_UNIT_DIP,
+                        QUOTED_MESSAGE_IMAGE_MAX_HEIGHT,
+                        resources.displayMetrics
+                    )
+
+                    quotedMessageImage?.maxHeight = px.toInt()
+                    val layoutParams = quotedMessageImage?.layoutParams as FlexboxLayout.LayoutParams
+                    layoutParams.flexGrow = 0f
+                    quotedMessageImage.layoutParams = layoutParams
+                    quotedMessageImage.load(previewImageUrl) {
+                        addHeader("Authorization", chatActivity.credentials!!)
+                    }
+                } ?: run {
+                    view.findViewById<ImageView>(R.id.quotedMessageImage)?.visibility = View.GONE
+                }
+            }
+
+            val quotedChatMessageView =
+                view.findViewById<RelativeLayout>(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<RelativeLayout>(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<ImageButton>(R.id.cancelReplyButton)?.setOnClickListener {
+            cancelReply()
+        }
+
+        binding.fragmentMessageInputView.findViewById<ImageButton>(R.id.cancelReplyButton)?.let {
+            viewThemeUtils.platform
+                .themeImageButton(it)
+        }
+
+        binding.fragmentMessageInputView.findViewById<MaterialButton>(R.id.playPauseBtn)?.let {
+            viewThemeUtils.material.colorMaterialButtonText(it)
+        }
+
+        binding.fragmentMessageInputView.findViewById<SeekBar>(R.id.seekbar)?.let {
+            viewThemeUtils.platform.themeHorizontalSeekBar(it)
+        }
+
+        binding.fragmentMessageInputView.findViewById<ImageView>(R.id.deleteVoiceRecording)?.let {
+            viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
+        }
+        binding.fragmentMessageInputView.findViewById<ImageView>(R.id.sendVoiceRecording)?.let {
+            viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
+        }
+
+        binding.fragmentMessageInputView.findViewById<ImageView>(R.id.microphoneEnabledInfo)?.let {
+            viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY)
+        }
+
+        binding.fragmentMessageInputView.findViewById<LinearLayout>(R.id.voice_preview_container)?.let {
+            viewThemeUtils.talk.themeOutgoingMessageBubble(it, true, false)
+        }
+
+        binding.fragmentMessageInputView.findViewById<MicInputCloud>(R.id.micInputCloud)?.let {
+            viewThemeUtils.talk.themeMicInputCloud(it)
+        }
+        binding.fragmentMessageInputView.findViewById<ImageView>(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<RelativeLayout>(R.id.quotedChatMessageView)
+        quote.visibility = View.GONE
+        quote.tag = null
+        binding.fragmentMessageInputView.findViewById<ImageButton>(R.id.attachmentButton)?.visibility = View.VISIBLE
+        chatActivity.messageInputViewModel.reply(null)
+    }
+}

+ 220 - 0
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)
+        }
+    }
+}

+ 112 - 0
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 <juliuslinus1@gmail.com>
+ * 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<ManagerState> = MutableLiveData()
+    val getManagerState: LiveData<ManagerState>
+        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()
+        }
+    }
+}

+ 143 - 0
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 <juliuslinus1@gmail.com>
+ * 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<Pair<Float, Float>> = MutableLiveData()
+    val getAudioValues: LiveData<Pair<Float, Float>>
+        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()
+    }
+}

+ 32 - 0
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 <juliuslinus1@gmail.com>
+ * 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()
+}

+ 138 - 0
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 <juliuslinus1@gmail.com>
+ * 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<Int> = MutableLiveData()
+    val mediaPlayerSeekBarPosition: LiveData<Int>
+        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()
+    }
+}

+ 165 - 0
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 <juliuslinus1@gmail.com>
+ * 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()
+    }
+}

+ 142 - 72
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<Disposable>()
 
-    object LifeCycleObserver : DefaultLifecycleObserver {
-        enum class LifeCycleFlag {
-            PAUSED,
-            RESUMED
-        }
-        lateinit var currentLifeCycleFlag: LifeCycleFlag
-        public val disposableSet = mutableSetOf<Disposable>()
+    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<AudioFocusRequestManager.ManagerState>
+        get() = audioFocusRequestManager.getManagerState
+
+    private val _recordTouchObserver: MutableLiveData<Float> = MutableLiveData()
+    val recordTouchObserver: LiveData<Float>
+        get() = _recordTouchObserver
+
+    private val _getVoiceRecordingInProgress: MutableLiveData<Boolean> = MutableLiveData()
+    val getVoiceRecordingInProgress: LiveData<Boolean>
+        get() = _getVoiceRecordingInProgress
+
+    private val _getVoiceRecordingLocked: MutableLiveData<Boolean> = MutableLiveData()
+    val getVoiceRecordingLocked: LiveData<Boolean>
+        get() = _getVoiceRecordingLocked
 
     private val _getFieldMapForChat: MutableLiveData<HashMap<String, Int>> = MutableLiveData()
     val getFieldMapForChat: LiveData<HashMap<String, Int>>
@@ -70,10 +99,6 @@ class ChatViewModel @Inject constructor(
 
     private val _getReminderExistState: MutableLiveData<ViewState> = MutableLiveData(GetReminderStartState)
 
-    var isPausedDueToBecomingNoisy = false
-    var receiverRegistered = false
-    var receiverUnregistered = false
-
     val getReminderExistState: LiveData<ViewState>
         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<ViewState> = MutableLiveData(GetCapabilitiesStartState)
     val getCapabilitiesViewState: LiveData<ViewState>
@@ -156,14 +182,6 @@ class ChatViewModel @Inject constructor(
     val reactionDeletedViewState: LiveData<ViewState>
         get() = _reactionDeletedViewState
 
-    object EditMessageStartState : ViewState
-    object EditMessageErrorState : ViewState
-    class EditMessageSuccessState(val messageEdited: ChatOverallSingleMessage) : ViewState
-
-    private val _editMessageViewState: MutableLiveData<ViewState> = MutableLiveData(EditMessageStartState)
-    val editMessageViewState: LiveData<ViewState>
-        get() = _editMessageViewState
-
     fun refreshChatParams(pullChatMessagesFieldMap: HashMap<String, Int>, 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<SpreedCapability> {
                     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<GenericOverall> {
                 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<GenericOverall> {
                 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<RoomOverall> {
                 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<GenericOverall> {
                 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<Response<*>> {
                 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<ChatOverallSingleMessage> {
                 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<GenericOverall> {
                 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<GenericOverall> {
                 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<GenericOverall> {
                 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<ReactionDeletedModel> {
                 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<ReactionAddedModel> {
                 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<ChatOverallSingleMessage> {
-                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<ConversationModel> {
@@ -566,7 +636,7 @@ class ChatViewModel @Inject constructor(
 
     inner class JoinRoomObserver : Observer<ConversationModel> {
         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<Reminder> {
         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<Reminder> {
         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<RoomsOverall> {
         override fun onSubscribe(d: Disposable) {
-            LifeCycleObserver.disposableSet.add(d)
+            disposableSet.add(d)
         }
 
         override fun onNext(roomsOverall: RoomsOverall) {

+ 219 - 0
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<Disposable>()
+
+    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<AudioFocusRequestManager.ManagerState>
+        get() = audioFocusRequestManager.getManagerState
+
+    private val _getRecordingTime: MutableLiveData<Long> = MutableLiveData(0L)
+    val getRecordingTime: LiveData<Long>
+        get() = _getRecordingTime
+
+    val micInputAudioObserver: LiveData<Pair<Float, Float>>
+        get() = audioRecorderManager.getAudioValues
+
+    val mediaPlayerSeekbarObserver: LiveData<Int>
+        get() = mediaPlayerManager.mediaPlayerSeekBarPosition
+
+    private val _getEditChatMessage: MutableLiveData<IMessage?> = MutableLiveData()
+    val getEditChatMessage: LiveData<IMessage?>
+        get() = _getEditChatMessage
+
+    private val _getReplyChatMessage: MutableLiveData<IMessage?> = MutableLiveData()
+    val getReplyChatMessage: LiveData<IMessage?>
+        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<ViewState> = MutableLiveData(SendChatMessageStartState)
+    val sendChatMessageViewState: LiveData<ViewState>
+        get() = _sendChatMessageViewState
+    object EditMessageStartState : ViewState
+    object EditMessageErrorState : ViewState
+    class EditMessageSuccessState(val messageEdited: ChatOverallSingleMessage) : ViewState
+
+    private val _editMessageViewState: MutableLiveData<ViewState> = MutableLiveData()
+    val editMessageViewState: LiveData<ViewState>
+        get() = _editMessageViewState
+
+    private val _isVoicePreviewPlaying: MutableLiveData<Boolean> = MutableLiveData(false)
+    val isVoicePreviewPlaying: LiveData<Boolean>
+        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<GenericOverall> {
+                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<ChatOverallSingleMessage> {
+                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)
+    }
+}

+ 40 - 0
app/src/main/java/com/nextcloud/talk/dagger/modules/ManagerModule.kt

@@ -0,0 +1,40 @@
+/*
+ * Nextcloud Talk - Android Client
+ *
+ * SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
+ * 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)
+    }
+}

+ 8 - 0
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)

+ 2 - 3
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()
             }
         }

+ 5 - 38
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"></TextView>
+                tools:text="Marcel is typing"/>
 
         </LinearLayout>
 
     </RelativeLayout>
 
-    <LinearLayout
+    <androidx.fragment.app.FragmentContainerView
+        android:id="@+id/fragment_container_activity_chat"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:orientation="vertical">
-
-        <include
-            android:id="@+id/editView"
-            layout="@layout/edit_message_view"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:layout_marginEnd="6dp"
-            android:visibility="gone">
-        </include>
-
-        <com.nextcloud.talk.ui.MessageInput
-            android:id="@+id/messageInputView"
-            android:layout_width="match_parent"
-            android:layout_height="wrap_content"
-            android:animateLayoutChanges="true"
-            android:inputType="textLongMessage|textAutoComplete"
-            android:maxLength="1000"
-            app:attachmentButtonBackground="@color/transparent"
-            app:attachmentButtonHeight="48dp"
-            app:attachmentButtonIcon="@drawable/ic_baseline_attach_file_24"
-            app:attachmentButtonMargin="0dp"
-            app:attachmentButtonWidth="48dp"
-            app:delayTypingStatus="200"
-            app:inputButtonDefaultBgColor="@color/transparent"
-            app:inputButtonDefaultBgDisabledColor="@color/transparent"
-            app:inputButtonDefaultBgPressedColor="@color/transparent"
-            app:inputButtonDefaultIconColor="@color/colorPrimary"
-            app:inputButtonHeight="48dp"
-            app:inputButtonMargin="0dp"
-            app:inputButtonWidth="48dp"
-            app:inputHint="@string/nc_hint_enter_a_message"
-            app:inputTextColor="@color/nc_incoming_text_default"
-            app:inputTextSize="16sp"
-            app:showAttachmentButton="true" />
-    </LinearLayout>
+        android:padding="0dp"
+        />
 </LinearLayout>

+ 47 - 0
app/src/main/res/layout/fragment_message_input.xml

@@ -0,0 +1,47 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Nextcloud Talk - Android Client
+  ~
+  ~ SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
+  ~ SPDX-License-Identifier: GPL-3.0-or-later
+-->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical">
+
+        <include
+            android:id="@+id/fragment_editView"
+            layout="@layout/edit_message_view"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="6dp">
+        </include>
+
+        <com.nextcloud.talk.ui.MessageInput
+            android:id="@+id/fragment_message_input_view"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:animateLayoutChanges="true"
+            android:inputType="textLongMessage|textAutoComplete"
+            android:maxLength="1000"
+            app:attachmentButtonBackground="@color/transparent"
+            app:attachmentButtonHeight="48dp"
+            app:attachmentButtonIcon="@drawable/ic_baseline_attach_file_24"
+            app:attachmentButtonMargin="0dp"
+            app:attachmentButtonWidth="48dp"
+            app:delayTypingStatus="200"
+            app:inputButtonDefaultBgColor="@color/transparent"
+            app:inputButtonDefaultBgDisabledColor="@color/transparent"
+            app:inputButtonDefaultBgPressedColor="@color/transparent"
+            app:inputButtonDefaultIconColor="@color/colorPrimary"
+            app:inputButtonHeight="48dp"
+            app:inputButtonMargin="0dp"
+            app:inputButtonWidth="48dp"
+            app:inputHint="@string/nc_hint_enter_a_message"
+            app:inputTextColor="@color/nc_incoming_text_default"
+            app:inputTextSize="16sp"
+            app:showAttachmentButton="true" />
+</LinearLayout>

+ 112 - 0
app/src/main/res/layout/fragment_message_input_voice_recording.xml

@@ -0,0 +1,112 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+  ~ Nextcloud Talk - Android Client
+  ~
+  ~ SPDX-FileCopyrightText: 2024 Julius Linus <juliuslinus1@gmail.com>
+  ~ SPDX-License-Identifier: GPL-3.0-or-later
+  -->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:gravity="center"
+    android:orientation="vertical">
+
+    <LinearLayout
+        android:id="@+id/voice_preview_container"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="@dimen/standard_margin"
+        android:orientation="horizontal"
+        android:gravity="center"
+        android:layout_marginHorizontal="@dimen/standard_margin"
+        android:background="@drawable/shape_grouped_outcoming_message"
+        tools:backgroundTint="@color/nc_grey"
+        android:visibility="gone"
+        tools:visibility="visible">
+
+        <com.google.android.material.button.MaterialButton
+            android:id="@+id/playPauseBtn"
+            style="@style/Widget.AppTheme.Button.IconButton"
+            android:layout_width="48dp"
+            android:layout_height="48dp"
+            android:layout_marginStart="@dimen/standard_margin"
+            android:contentDescription="@string/play_pause_voice_message"
+            app:cornerRadius="@dimen/button_corner_radius"
+            app:icon="@drawable/ic_baseline_play_arrow_voice_message_24"
+            app:iconSize="30dp"
+            app:iconTint="@color/high_emphasis_text"
+            app:rippleColor="#1FFFFFFF" />
+
+        <SeekBar
+            android:id="@+id/seekbar"
+            style="@style/Nextcloud.Material.Outgoing.SeekBar"
+            android:layout_width="match_parent"
+            android:layout_height="30dp"
+            android:layout_marginEnd="@dimen/standard_margin"
+            android:thumb="@drawable/voice_message_outgoing_seek_bar_slider"
+            tools:progress="50"
+            tools:progressTint="@color/hwSecurityRed"
+            tools:progressBackgroundTint="@color/blue"/>
+    </LinearLayout>
+
+    <Chronometer
+        android:id="@+id/audioRecordDuration"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:textSize="16sp"
+        android:textStyle="bold"
+        android:textColor="@color/low_emphasis_text"
+        android:paddingStart="5dp"
+        android:paddingEnd="5dp"
+        android:background="@color/bg_default"
+        />
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        android:gravity="center"
+        android:weightSum="3"
+        >
+
+        <ImageView
+            android:id="@+id/deleteVoiceRecording"
+            android:layout_width="48dp"
+            android:layout_height="48dp"
+            android:layout_marginVertical="@dimen/standard_margin"
+            android:scaleType="centerInside"
+            android:src="@drawable/ic_delete"
+            android:contentDescription="@null"
+            android:background="?android:attr/selectableItemBackgroundBorderless"
+            android:layout_weight="1"
+            />
+
+        <com.nextcloud.talk.ui.MicInputCloud
+            android:id="@+id/micInputCloud"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            app:playIcon="@drawable/ic_refresh"
+            app:pauseIcon="@drawable/baseline_stop_24"
+            android:background="?android:attr/selectableItemBackgroundBorderless"
+            android:layout_weight="1"
+            />
+
+
+        <ImageView
+            android:id="@+id/sendVoiceRecording"
+            android:layout_width="48dp"
+            android:layout_height="48dp"
+            android:layout_marginVertical="@dimen/standard_margin"
+            android:scaleType="centerInside"
+            android:src="@drawable/ic_send"
+            android:contentDescription="@null"
+            android:background="?android:attr/selectableItemBackgroundBorderless"
+            android:layout_weight="1"
+            />
+    </LinearLayout>
+
+
+</LinearLayout>

+ 1 - 1
app/src/main/res/values/strings.xml

@@ -15,7 +15,7 @@ How to translate with transifex:
 -->
 
 <resources>
-<!--
+    <!--
   ~ Nextcloud Talk - Android Client
   ~
   ~ SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors

+ 1 - 1
scripts/analysis/lint-results.txt

@@ -1,2 +1,2 @@
 DO NOT TOUCH; GENERATED BY DRONE
-      <span class="mdl-layout-title">Lint Report: 119 errors and 81 warnings</span>
+      <span class="mdl-layout-title">Lint Report: 10 errors and 79 warnings</span>

Некоторые файлы не были показаны из-за большого количества измененных файлов