mirror of
https://github.com/nextcloud/talk-android
synced 2025-06-19 03:29:28 +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.BusModule
|
||||||
import com.nextcloud.talk.dagger.modules.ContextModule
|
import com.nextcloud.talk.dagger.modules.ContextModule
|
||||||
import com.nextcloud.talk.dagger.modules.DatabaseModule
|
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.RepositoryModule
|
||||||
import com.nextcloud.talk.dagger.modules.RestModule
|
import com.nextcloud.talk.dagger.modules.RestModule
|
||||||
import com.nextcloud.talk.dagger.modules.UtilsModule
|
import com.nextcloud.talk.dagger.modules.UtilsModule
|
||||||
@ -77,7 +78,8 @@ import javax.inject.Singleton
|
|||||||
ViewModelModule::class,
|
ViewModelModule::class,
|
||||||
RepositoryModule::class,
|
RepositoryModule::class,
|
||||||
UtilsModule::class,
|
UtilsModule::class,
|
||||||
ThemeModule::class
|
ThemeModule::class,
|
||||||
|
ManagerModule::class
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@Singleton
|
@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
|
package com.nextcloud.talk.chat.viewmodels
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.lifecycle.DefaultLifecycleObserver
|
import androidx.lifecycle.DefaultLifecycleObserver
|
||||||
import androidx.lifecycle.LifecycleOwner
|
import androidx.lifecycle.LifecycleOwner
|
||||||
@ -13,7 +15,10 @@ import androidx.lifecycle.LiveData
|
|||||||
import androidx.lifecycle.MutableLiveData
|
import androidx.lifecycle.MutableLiveData
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import com.nextcloud.talk.chat.data.ChatRepository
|
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.data.user.model.User
|
||||||
|
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
|
||||||
import com.nextcloud.talk.models.domain.ConversationModel
|
import com.nextcloud.talk.models.domain.ConversationModel
|
||||||
import com.nextcloud.talk.models.domain.ReactionAddedModel
|
import com.nextcloud.talk.models.domain.ReactionAddedModel
|
||||||
import com.nextcloud.talk.models.domain.ReactionDeletedModel
|
import com.nextcloud.talk.models.domain.ReactionDeletedModel
|
||||||
@ -31,25 +36,29 @@ import io.reactivex.android.schedulers.AndroidSchedulers
|
|||||||
import io.reactivex.disposables.Disposable
|
import io.reactivex.disposables.Disposable
|
||||||
import io.reactivex.schedulers.Schedulers
|
import io.reactivex.schedulers.Schedulers
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@Suppress("TooManyFunctions", "LongParameterList")
|
@Suppress("TooManyFunctions", "LongParameterList")
|
||||||
class ChatViewModel @Inject constructor(
|
class ChatViewModel @Inject constructor(
|
||||||
private val chatRepository: ChatRepository,
|
private val chatRepository: ChatRepository,
|
||||||
private val reactionsRepository: ReactionsRepository
|
private val reactionsRepository: ReactionsRepository,
|
||||||
) : ViewModel() {
|
private val mediaRecorderManager: MediaRecorderManager,
|
||||||
|
private val audioFocusRequestManager: AudioFocusRequestManager
|
||||||
|
) : ViewModel(), DefaultLifecycleObserver {
|
||||||
|
|
||||||
object LifeCycleObserver : DefaultLifecycleObserver {
|
|
||||||
enum class LifeCycleFlag {
|
enum class LifeCycleFlag {
|
||||||
PAUSED,
|
PAUSED,
|
||||||
RESUMED
|
RESUMED,
|
||||||
|
STOPPED
|
||||||
}
|
}
|
||||||
lateinit var currentLifeCycleFlag: LifeCycleFlag
|
lateinit var currentLifeCycleFlag: LifeCycleFlag
|
||||||
public val disposableSet = mutableSetOf<Disposable>()
|
val disposableSet = mutableSetOf<Disposable>()
|
||||||
|
|
||||||
override fun onResume(owner: LifecycleOwner) {
|
override fun onResume(owner: LifecycleOwner) {
|
||||||
super.onResume(owner)
|
super.onResume(owner)
|
||||||
currentLifeCycleFlag = LifeCycleFlag.RESUMED
|
currentLifeCycleFlag = LifeCycleFlag.RESUMED
|
||||||
|
mediaRecorderManager.handleOnResume()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause(owner: LifecycleOwner) {
|
override fun onPause(owner: LifecycleOwner) {
|
||||||
@ -57,8 +66,28 @@ class ChatViewModel @Inject constructor(
|
|||||||
currentLifeCycleFlag = LifeCycleFlag.PAUSED
|
currentLifeCycleFlag = LifeCycleFlag.PAUSED
|
||||||
disposableSet.forEach { disposable -> disposable.dispose() }
|
disposableSet.forEach { disposable -> disposable.dispose() }
|
||||||
disposableSet.clear()
|
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()
|
private val _getFieldMapForChat: MutableLiveData<HashMap<String, Int>> = MutableLiveData()
|
||||||
val getFieldMapForChat: LiveData<HashMap<String, Int>>
|
val getFieldMapForChat: LiveData<HashMap<String, Int>>
|
||||||
@ -70,10 +99,6 @@ class ChatViewModel @Inject constructor(
|
|||||||
|
|
||||||
private val _getReminderExistState: MutableLiveData<ViewState> = MutableLiveData(GetReminderStartState)
|
private val _getReminderExistState: MutableLiveData<ViewState> = MutableLiveData(GetReminderStartState)
|
||||||
|
|
||||||
var isPausedDueToBecomingNoisy = false
|
|
||||||
var receiverRegistered = false
|
|
||||||
var receiverUnregistered = false
|
|
||||||
|
|
||||||
val getReminderExistState: LiveData<ViewState>
|
val getReminderExistState: LiveData<ViewState>
|
||||||
get() = _getReminderExistState
|
get() = _getReminderExistState
|
||||||
|
|
||||||
@ -94,7 +119,8 @@ class ChatViewModel @Inject constructor(
|
|||||||
|
|
||||||
object GetCapabilitiesStartState : ViewState
|
object GetCapabilitiesStartState : ViewState
|
||||||
object GetCapabilitiesErrorState : 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)
|
private val _getCapabilitiesViewState: MutableLiveData<ViewState> = MutableLiveData(GetCapabilitiesStartState)
|
||||||
val getCapabilitiesViewState: LiveData<ViewState>
|
val getCapabilitiesViewState: LiveData<ViewState>
|
||||||
@ -156,14 +182,6 @@ class ChatViewModel @Inject constructor(
|
|||||||
val reactionDeletedViewState: LiveData<ViewState>
|
val reactionDeletedViewState: LiveData<ViewState>
|
||||||
get() = _reactionDeletedViewState
|
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) {
|
fun refreshChatParams(pullChatMessagesFieldMap: HashMap<String, Int>, overrideRefresh: Boolean = false) {
|
||||||
if (pullChatMessagesFieldMap != _getFieldMapForChat.value || overrideRefresh) {
|
if (pullChatMessagesFieldMap != _getFieldMapForChat.value || overrideRefresh) {
|
||||||
_getFieldMapForChat.postValue(pullChatMessagesFieldMap)
|
_getFieldMapForChat.postValue(pullChatMessagesFieldMap)
|
||||||
@ -180,21 +198,30 @@ class ChatViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) {
|
fun getCapabilities(user: User, token: String, conversationModel: ConversationModel) {
|
||||||
_getCapabilitiesViewState.value = GetCapabilitiesStartState
|
Log.d(TAG, "Remote server ${conversationModel.remoteServer}")
|
||||||
|
|
||||||
if (conversationModel.remoteServer.isNullOrEmpty()) {
|
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 {
|
} else {
|
||||||
chatRepository.getCapabilities(user, token)
|
chatRepository.getCapabilities(user, token)
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
?.observeOn(AndroidSchedulers.mainThread())
|
?.observeOn(AndroidSchedulers.mainThread())
|
||||||
?.subscribe(object : Observer<SpreedCapability> {
|
?.subscribe(object : Observer<SpreedCapability> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNext(spreedCapabilities: SpreedCapability) {
|
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) {
|
override fun onError(e: Throwable) {
|
||||||
@ -238,7 +265,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
?.observeOn(AndroidSchedulers.mainThread())
|
?.observeOn(AndroidSchedulers.mainThread())
|
||||||
?.subscribe(object : Observer<GenericOverall> {
|
?.subscribe(object : Observer<GenericOverall> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNext(genericOverall: GenericOverall) {
|
override fun onNext(genericOverall: GenericOverall) {
|
||||||
@ -262,7 +289,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
?.observeOn(AndroidSchedulers.mainThread())
|
?.observeOn(AndroidSchedulers.mainThread())
|
||||||
?.subscribe(object : Observer<GenericOverall> {
|
?.subscribe(object : Observer<GenericOverall> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(e: Throwable) {
|
override fun onError(e: Throwable) {
|
||||||
@ -275,6 +302,8 @@ class ChatViewModel @Inject constructor(
|
|||||||
|
|
||||||
override fun onNext(t: GenericOverall) {
|
override fun onNext(t: GenericOverall) {
|
||||||
_leaveRoomViewState.value = LeaveRoomSuccessState(funToCallWhenLeaveSuccessful)
|
_leaveRoomViewState.value = LeaveRoomSuccessState(funToCallWhenLeaveSuccessful)
|
||||||
|
_getCapabilitiesViewState.value = GetCapabilitiesStartState
|
||||||
|
_getRoomViewState.value = GetRoomStartState
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -285,7 +314,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(object : Observer<RoomOverall> {
|
.subscribe(object : Observer<RoomOverall> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(e: Throwable) {
|
override fun onError(e: Throwable) {
|
||||||
@ -322,7 +351,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
?.observeOn(AndroidSchedulers.mainThread())
|
?.observeOn(AndroidSchedulers.mainThread())
|
||||||
?.subscribe(object : Observer<GenericOverall> {
|
?.subscribe(object : Observer<GenericOverall> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(e: Throwable) {
|
override fun onError(e: Throwable) {
|
||||||
@ -342,12 +371,12 @@ class ChatViewModel @Inject constructor(
|
|||||||
fun pullChatMessages(credentials: String, url: String) {
|
fun pullChatMessages(credentials: String, url: String) {
|
||||||
chatRepository.pullChatMessages(credentials, url, _getFieldMapForChat.value!!)
|
chatRepository.pullChatMessages(credentials, url, _getFieldMapForChat.value!!)
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.takeUntil { (LifeCycleObserver.currentLifeCycleFlag == LifeCycleObserver.LifeCycleFlag.PAUSED) }
|
.takeUntil { (currentLifeCycleFlag == LifeCycleFlag.PAUSED) }
|
||||||
?.observeOn(AndroidSchedulers.mainThread())
|
?.observeOn(AndroidSchedulers.mainThread())
|
||||||
?.subscribe(object : Observer<Response<*>> {
|
?.subscribe(object : Observer<Response<*>> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
Log.d(TAG, "pullChatMessages - pullChatMessages SUBSCRIBE")
|
Log.d(TAG, "pullChatMessages - pullChatMessages SUBSCRIBE")
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(e: Throwable) {
|
override fun onError(e: Throwable) {
|
||||||
@ -373,7 +402,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
?.observeOn(AndroidSchedulers.mainThread())
|
?.observeOn(AndroidSchedulers.mainThread())
|
||||||
?.subscribe(object : Observer<ChatOverallSingleMessage> {
|
?.subscribe(object : Observer<ChatOverallSingleMessage> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(e: Throwable) {
|
override fun onError(e: Throwable) {
|
||||||
@ -402,7 +431,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(object : Observer<GenericOverall> {
|
.subscribe(object : Observer<GenericOverall> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(e: Throwable) {
|
override fun onError(e: Throwable) {
|
||||||
@ -425,7 +454,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
?.observeOn(AndroidSchedulers.mainThread())
|
?.observeOn(AndroidSchedulers.mainThread())
|
||||||
?.subscribe(object : Observer<GenericOverall> {
|
?.subscribe(object : Observer<GenericOverall> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNext(genericOverall: GenericOverall) {
|
override fun onNext(genericOverall: GenericOverall) {
|
||||||
@ -454,7 +483,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
?.observeOn(AndroidSchedulers.mainThread())
|
?.observeOn(AndroidSchedulers.mainThread())
|
||||||
?.subscribe(object : Observer<GenericOverall> {
|
?.subscribe(object : Observer<GenericOverall> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNext(genericOverall: GenericOverall) {
|
override fun onNext(genericOverall: GenericOverall) {
|
||||||
@ -477,7 +506,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
?.observeOn(AndroidSchedulers.mainThread())
|
?.observeOn(AndroidSchedulers.mainThread())
|
||||||
?.subscribe(object : Observer<ReactionDeletedModel> {
|
?.subscribe(object : Observer<ReactionDeletedModel> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(e: Throwable) {
|
override fun onError(e: Throwable) {
|
||||||
@ -502,7 +531,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
?.observeOn(AndroidSchedulers.mainThread())
|
?.observeOn(AndroidSchedulers.mainThread())
|
||||||
?.subscribe(object : Observer<ReactionAddedModel> {
|
?.subscribe(object : Observer<ReactionAddedModel> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(e: Throwable) {
|
override fun onError(e: Throwable) {
|
||||||
@ -521,28 +550,69 @@ class ChatViewModel @Inject constructor(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fun editChatMessage(credentials: String, url: String, text: String) {
|
fun startAudioRecording(context: Context, currentConversation: ConversationModel) {
|
||||||
chatRepository.editChatMessage(credentials, url, text)
|
audioFocusRequestManager.audioFocusRequest(true) {
|
||||||
.subscribeOn(Schedulers.io())
|
Log.d(TAG, "Recording Started")
|
||||||
?.observeOn(AndroidSchedulers.mainThread())
|
mediaRecorderManager.start(context, currentConversation)
|
||||||
?.subscribe(object : Observer<ChatOverallSingleMessage> {
|
_getVoiceRecordingInProgress.postValue(true)
|
||||||
override fun onSubscribe(d: Disposable) {
|
}
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onError(e: Throwable) {
|
fun stopAudioRecording() {
|
||||||
Log.e(TAG, "failed to edit message", e)
|
audioFocusRequestManager.audioFocusRequest(false) {
|
||||||
_editMessageViewState.value = EditMessageErrorState
|
mediaRecorderManager.stop()
|
||||||
|
_getVoiceRecordingInProgress.postValue(false)
|
||||||
|
Log.d(TAG, "Recording stopped")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onComplete() {
|
fun stopAndSendAudioRecording(room: String, displayName: String, metaData: String) {
|
||||||
// unused atm
|
stopAudioRecording()
|
||||||
|
|
||||||
|
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()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNext(messageEdited: ChatOverallSingleMessage) {
|
fun getCurrentVoiceRecordFile(): String {
|
||||||
_editMessageViewState.value = EditMessageSuccessState(messageEdited)
|
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> {
|
inner class GetRoomObserver : Observer<ConversationModel> {
|
||||||
@ -566,7 +636,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
|
|
||||||
inner class JoinRoomObserver : Observer<ConversationModel> {
|
inner class JoinRoomObserver : Observer<ConversationModel> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNext(conversationModel: ConversationModel) {
|
override fun onNext(conversationModel: ConversationModel) {
|
||||||
@ -585,7 +655,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
|
|
||||||
inner class SetReminderObserver : Observer<Reminder> {
|
inner class SetReminderObserver : Observer<Reminder> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNext(reminder: Reminder) {
|
override fun onNext(reminder: Reminder) {
|
||||||
@ -603,7 +673,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
|
|
||||||
inner class GetReminderObserver : Observer<Reminder> {
|
inner class GetReminderObserver : Observer<Reminder> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNext(reminder: Reminder) {
|
override fun onNext(reminder: Reminder) {
|
||||||
@ -622,7 +692,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
|
|
||||||
inner class CheckForNoteToSelfObserver : Observer<RoomsOverall> {
|
inner class CheckForNoteToSelfObserver : Observer<RoomsOverall> {
|
||||||
override fun onSubscribe(d: Disposable) {
|
override fun onSubscribe(d: Disposable) {
|
||||||
LifeCycleObserver.disposableSet.add(d)
|
disposableSet.add(d)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNext(roomsOverall: RoomsOverall) {
|
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.ViewModel
|
||||||
import androidx.lifecycle.ViewModelProvider
|
import androidx.lifecycle.ViewModelProvider
|
||||||
import com.nextcloud.talk.chat.viewmodels.ChatViewModel
|
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.ConversationViewModel
|
||||||
import com.nextcloud.talk.conversation.viewmodel.RenameConversationViewModel
|
import com.nextcloud.talk.conversation.viewmodel.RenameConversationViewModel
|
||||||
import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel
|
import com.nextcloud.talk.conversationinfo.viewmodel.ConversationInfoViewModel
|
||||||
@ -118,6 +119,13 @@ abstract class ViewModelModule {
|
|||||||
@ViewModelKey(ChatViewModel::class)
|
@ViewModelKey(ChatViewModel::class)
|
||||||
abstract fun chatViewModel(viewModel: ChatViewModel): ViewModel
|
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
|
@Binds
|
||||||
@IntoMap
|
@IntoMap
|
||||||
@ViewModelKey(ConversationInfoViewModel::class)
|
@ViewModelKey(ConversationInfoViewModel::class)
|
||||||
|
@ -335,8 +335,7 @@ class MessageActionsDialog(
|
|||||||
|
|
||||||
private fun initMenuEditMessage(visible: Boolean) {
|
private fun initMenuEditMessage(visible: Boolean) {
|
||||||
dialogMessageActionsBinding.menuEditMessage.setOnClickListener {
|
dialogMessageActionsBinding.menuEditMessage.setOnClickListener {
|
||||||
chatActivity.editMessage(message)
|
chatActivity.messageInputViewModel.edit(message)
|
||||||
Log.d("EDIT MESSAGE", "$message")
|
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -357,7 +356,7 @@ class MessageActionsDialog(
|
|||||||
private fun initMenuReplyToMessage(visible: Boolean) {
|
private fun initMenuReplyToMessage(visible: Boolean) {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
dialogMessageActionsBinding.menuReplyToMessage.setOnClickListener {
|
dialogMessageActionsBinding.menuReplyToMessage.setOnClickListener {
|
||||||
chatActivity.replyToMessage(message)
|
chatActivity.messageInputViewModel.reply(message)
|
||||||
dismiss()
|
dismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -233,49 +233,16 @@
|
|||||||
android:maxLines="2"
|
android:maxLines="2"
|
||||||
android:textColor="@color/low_emphasis_text"
|
android:textColor="@color/low_emphasis_text"
|
||||||
tools:ignore="Overdraw"
|
tools:ignore="Overdraw"
|
||||||
tools:text="Marcel is typing"></TextView>
|
tools:text="Marcel is typing"/>
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<androidx.fragment.app.FragmentContainerView
|
||||||
|
android:id="@+id/fragment_container_activity_chat"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical">
|
android:padding="0dp"
|
||||||
|
/>
|
||||||
<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>
|
|
||||||
</LinearLayout>
|
</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>
|
@ -1,2 +1,2 @@
|
|||||||
DO NOT TOUCH; GENERATED BY DRONE
|
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