mirror of
https://github.com/nextcloud/talk-android
synced 2025-06-18 19:19:33 +01:00
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:
parent
727a66f7c8
commit
6a01ebf630
@ -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
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
@ -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
app/src/main/res/layout/fragment_message_input.xml
Normal file
47
app/src/main/res/layout/fragment_message_input.xml
Normal 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>
|
@ -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>
|
@ -15,7 +15,7 @@ How to translate with transifex:
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<!--
|
||||
<!--
|
||||
~ Nextcloud Talk - Android Client
|
||||
~
|
||||
~ SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
|
||||
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user