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>
This commit is contained in:
Julius Linus 2024-04-01 09:00:09 -05:00 committed by Marcel Hibbe
parent 727a66f7c8
commit 6a01ebf630
No known key found for this signature in database
GPG Key ID: C793F8B59F43CE7B
19 changed files with 2329 additions and 1479 deletions

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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()
}
}
}

View File

@ -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()
}
}

View File

@ -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()
}

View File

@ -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()
}
}

View File

@ -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()
}
}

View File

@ -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 {
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
}
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
currentLifeCycleFlag = LifeCycleFlag.PAUSED
disposableSet.forEach { disposable -> disposable.dispose() }
disposableSet.clear()
}
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
mediaRecorderManager.handleOnResume()
}
override fun onPause(owner: LifecycleOwner) {
super.onPause(owner)
currentLifeCycleFlag = LifeCycleFlag.PAUSED
disposableSet.forEach { disposable -> disposable.dispose() }
disposableSet.clear()
mediaRecorderManager.handleOnPause()
}
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) {

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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()
}
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

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

View File

@ -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>