diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 0ec4bad4e..fc03dd2bd 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -29,6 +29,7 @@ package com.nextcloud.talk.chat import android.Manifest +import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.content.ClipData import android.content.ClipboardManager @@ -41,6 +42,8 @@ import android.database.Cursor import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable +import android.media.AudioFormat +import android.media.AudioRecord import android.media.MediaPlayer import android.media.MediaRecorder import android.net.Uri @@ -63,15 +66,21 @@ import android.view.Menu import android.view.MenuItem import android.view.MotionEvent import android.view.View +import android.view.View.OnTouchListener import android.view.animation.AccelerateDecelerateInterpolator +import android.view.animation.AccelerateInterpolator import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.LinearInterpolator import android.widget.AbsListView import android.widget.ImageButton import android.widget.ImageView +import android.widget.LinearLayout import android.widget.PopupMenu import android.widget.RelativeLayout +import android.widget.RelativeLayout.BELOW +import android.widget.RelativeLayout.LayoutParams +import android.widget.SeekBar import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.appcompat.app.AlertDialog @@ -79,6 +88,7 @@ import androidx.appcompat.view.ContextThemeWrapper import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.content.PermissionChecker +import androidx.core.content.PermissionChecker.PERMISSION_GRANTED import androidx.core.graphics.drawable.toBitmap import androidx.core.text.bold import androidx.core.widget.doAfterTextChanged @@ -100,6 +110,7 @@ import coil.request.ImageRequest import coil.target.Target import coil.transform.CircleCropTransformation import com.google.android.flexbox.FlexboxLayout +import com.google.android.material.button.MaterialButton import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.talk.BuildConfig @@ -166,6 +177,7 @@ import com.nextcloud.talk.shareditems.activities.SharedItemsActivity import com.nextcloud.talk.signaling.SignalingMessageReceiver import com.nextcloud.talk.signaling.SignalingMessageSender import com.nextcloud.talk.translate.ui.TranslateActivity +import com.nextcloud.talk.ui.MicInputCloud import com.nextcloud.talk.ui.bottom.sheet.ProfileBottomSheet import com.nextcloud.talk.ui.dialog.AttachmentDialog import com.nextcloud.talk.ui.dialog.MessageActionsDialog @@ -312,13 +324,22 @@ class ChatActivity : private lateinit var sharedText: String var isVoiceRecordingInProgress: Boolean = false var currentVoiceRecordFile: String = "" - + var isVoiceRecordingLocked: Boolean = false + private var isVoicePreviewPlaying: Boolean = false private var recorder: MediaRecorder? = null - + private var voicePreviewMediaPlayer: MediaPlayer? = null + private var voicePreviewObjectAnimator: ObjectAnimator? = null var mediaPlayer: MediaPlayer? = null lateinit var mediaPlayerHandler: Handler private var currentlyPlayedVoiceMessage: ChatMessage? = null - + private lateinit var micInputAudioRecorder: AudioRecord + private var micInputAudioRecordThread: Thread? = null + private var isMicInputAudioThreadRunning: Boolean = false + private val bufferSize = AudioRecord.getMinBufferSize( + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT + ) private lateinit var participantPermissions: ParticipantPermissions private var videoURI: Uri? = null @@ -461,6 +482,16 @@ class ChatActivity : override fun onStop() { super.onStop() active = false + stopPreviewVoicePlaying() + if (isMicInputAudioThreadRunning) { + stopMicInputRecordingAnimation() + } + if (isVoiceRecordingInProgress) { + stopAudioRecording() + } + if (currentlyPlayedVoiceMessage != null) { + stopMediaPlayer(currentlyPlayedVoiceMessage!!) + } } @Suppress("LongMethod") @@ -584,14 +615,7 @@ class ChatActivity : initSmileyKeyboardToggler() - binding.messageInputView.findViewById(R.id.cancelReplyButton)?.setOnClickListener { - cancelReply() - } - - binding.messageInputView.findViewById(R.id.cancelReplyButton)?.let { - viewThemeUtils.platform - .themeImageButton(it) - } + themeMessageInputView() cancelNotificationsForCurrentConversation() @@ -626,6 +650,8 @@ class ChatActivity : binding.let { viewThemeUtils.material.colorMaterialButtonPrimaryTonal(it.scrollDownButton) } + binding.let { viewThemeUtils.material.themeFAB(it.voiceRecordingLock) } + binding.let { viewThemeUtils.material.colorMaterialButtonPrimaryFilled(it.popupBubbleView) } binding.messageInputView.setPadding(0, 0, 0, 0) @@ -654,6 +680,15 @@ class ChatActivity : } }) + initMessageInputView() + + loadAvatarForStatusBar() + setActionBarTitle() + + viewThemeUtils.material.colorToolbarOverflowIcon(binding.chatToolbar) + } + + private fun initMessageInputView() { val filters = arrayOfNulls(1) val lengthFilter = CapabilitiesUtilNew.getMessageMaxLength(conversationUser) @@ -734,13 +769,46 @@ class ChatActivity : binding.messageInputView.button?.contentDescription = resources?.getString(R.string.nc_description_send_message_button) + } + private fun themeMessageInputView() { binding.messageInputView.button?.let { viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) } - loadAvatarForStatusBar() - setActionBarTitle() + binding.messageInputView.findViewById(R.id.cancelReplyButton)?.setOnClickListener { + cancelReply() + } - viewThemeUtils.material.colorToolbarOverflowIcon(binding.chatToolbar) + binding.messageInputView.findViewById(R.id.cancelReplyButton)?.let { + viewThemeUtils.platform + .themeImageButton(it) + } + + binding.messageInputView.findViewById(R.id.playPauseBtn)?.let { + viewThemeUtils.material.colorMaterialButtonText(it) + } + + binding.messageInputView.findViewById(R.id.seekbar)?.let { + viewThemeUtils.platform.themeHorizontalSeekBar(it) + } + + binding.messageInputView.findViewById(R.id.deleteVoiceRecording)?.let { + viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) + } + binding.messageInputView.findViewById(R.id.sendVoiceRecording)?.let { + viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) + } + + binding.messageInputView.findViewById(R.id.microphoneEnabledInfo)?.let { + viewThemeUtils.platform.colorImageView(it, ColorRole.PRIMARY) + } + + binding.messageInputView.findViewById(R.id.voice_preview_container)?.let { + viewThemeUtils.talk.themeOutgoingMessageBubble(it, true, false) + } + + binding.messageInputView.findViewById(R.id.micInputCloud)?.let { + viewThemeUtils.talk.themeMicInputCloud(it) + } } private fun setupActionBar() { @@ -890,10 +958,18 @@ class ChatActivity : @SuppressLint("ClickableViewAccessibility") private fun initVoiceRecordButton() { - showMicrophoneButton(true) + if (!isVoiceRecordingLocked) { + showMicrophoneButton(true) + } else if (isVoiceRecordingInProgress) { + binding.messageInputView.playPauseBtn.visibility = View.GONE + binding.messageInputView.seekBar.visibility = View.GONE + } else { + binding.messageInputView.micInputCloud.setState(MicInputCloud.ViewState.PAUSED_STATE) + } - binding.messageInputView.messageInput?.doAfterTextChanged { - if (binding.messageInputView.messageInput?.text?.isEmpty() == true) { + isVoicePreviewPlaying = false + binding.messageInputView.messageInput.doAfterTextChanged { + if (binding.messageInputView.messageInput.text?.isEmpty() == true) { showMicrophoneButton(true) } else { showMicrophoneButton(false) @@ -902,12 +978,103 @@ class ChatActivity : var sliderInitX = 0F var downX = 0f + var originY = 0f var deltaX = 0f + var deltaY = 0f var voiceRecordStartTime = 0L var voiceRecordEndTime = 0L + var voiceRecordPauseTime = 0L + val micInputCloudLayoutParams: LayoutParams = binding.messageInputView.micInputCloud + .layoutParams as LayoutParams - binding.messageInputView.recordAudioButton?.setOnTouchListener(object : View.OnTouchListener { + val deleteVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.deleteVoiceRecording + .layoutParams as LayoutParams + + val sendVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.sendVoiceRecording + .layoutParams as LayoutParams + + // this is so that the seekbar is no longer draggable + binding.messageInputView.seekBar.setOnTouchListener(OnTouchListener { _, _ -> true }) + + binding.messageInputView.micInputCloud.setOnClickListener { + if (isVoiceRecordingInProgress) { + recorder?.stop() + stopMicInputRecordingAnimation() + voiceRecordPauseTime = binding.messageInputView.audioRecordDuration.base - SystemClock.elapsedRealtime() + binding.messageInputView.audioRecordDuration.stop() + binding.messageInputView.audioRecordDuration.visibility = View.GONE + binding.messageInputView.playPauseBtn.visibility = View.VISIBLE + binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable( + context, + R.drawable.ic_baseline_play_arrow_voice_message_24 + ) + binding.messageInputView.seekBar.visibility = View.VISIBLE + binding.messageInputView.seekBar.progress = 0 + binding.messageInputView.seekBar.max = 0 + micInputCloudLayoutParams.removeRule(BELOW) + micInputCloudLayoutParams.addRule(BELOW, R.id.voice_preview_container) + deleteVoiceRecordingLayoutParams.removeRule(BELOW) + deleteVoiceRecordingLayoutParams.addRule(BELOW, R.id.voice_preview_container) + sendVoiceRecordingLayoutParams.removeRule(BELOW) + sendVoiceRecordingLayoutParams.addRule(BELOW, R.id.voice_preview_container) + } else { + restartAudio() + startMicInputRecordingAnimation() + binding.messageInputView.audioRecordDuration.base = SystemClock.elapsedRealtime() + binding.messageInputView.audioRecordDuration.start() + binding.messageInputView.playPauseBtn.visibility = View.GONE + binding.messageInputView.seekBar.visibility = View.GONE + binding.messageInputView.audioRecordDuration.visibility = View.VISIBLE + micInputCloudLayoutParams.removeRule(BELOW) + micInputCloudLayoutParams.addRule(BELOW, R.id.audioRecordDuration) + deleteVoiceRecordingLayoutParams.removeRule(BELOW) + deleteVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration) + sendVoiceRecordingLayoutParams.removeRule(BELOW) + sendVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration) + } + + isVoiceRecordingInProgress = !isVoiceRecordingInProgress + } + + binding.messageInputView.deleteVoiceRecording.setOnClickListener { + stopAndDiscardAudioRecording() + endVoiceRecordingUI() + stopMicInputRecordingAnimation() + binding.messageInputView.slideToCancelDescription.x = sliderInitX + } + + binding.messageInputView.sendVoiceRecording.setOnClickListener { + stopAndSendAudioRecording() + endVoiceRecordingUI() + stopMicInputRecordingAnimation() + binding.messageInputView.slideToCancelDescription.x = sliderInitX + } + + binding.messageInputView.playPauseBtn.setOnClickListener { + Log.d(TAG, "is voice preview playing $isVoicePreviewPlaying") + if (isVoicePreviewPlaying) { + Log.d(TAG, "Paused") + pausePreviewVoicePlaying() + binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable( + context, + R.drawable + .ic_baseline_play_arrow_voice_message_24 + ) + isVoicePreviewPlaying = false + } else { + Log.d(TAG, "Started") + startPreviewVoicePlaying() + binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable( + context, + R.drawable + .ic_baseline_pause_voice_message_24 + ) + isVoicePreviewPlaying = true + } + } + + binding.messageInputView.recordAudioButton.setOnTouchListener(object : OnTouchListener { override fun onTouch(v: View?, event: MotionEvent?): Boolean { v?.performClick() // ????????? when (event?.action) { @@ -926,6 +1093,7 @@ class ChatActivity : setVoiceRecordFileName() startAudioRecording(currentVoiceRecordFile) downX = event.x + originY = event.y showRecordAudioUi(true) } @@ -936,13 +1104,16 @@ class ChatActivity : } stopAndDiscardAudioRecording() - showRecordAudioUi(false) - binding.messageInputView.slideToCancelDescription?.x = sliderInitX + endVoiceRecordingUI() + binding.messageInputView.slideToCancelDescription.x = sliderInitX } MotionEvent.ACTION_UP -> { Log.d(TAG, "ACTION_UP. stop recording??") - if (!isVoiceRecordingInProgress || !isRecordAudioPermissionGranted()) { + if (!isVoiceRecordingInProgress || + !isRecordAudioPermissionGranted() || + isVoiceRecordingLocked + ) { return true } showRecordAudioUi(false) @@ -964,7 +1135,7 @@ class ChatActivity : stopAndSendAudioRecording() } - binding.messageInputView.slideToCancelDescription?.x = sliderInitX + binding.messageInputView.slideToCancelDescription.x = sliderInitX } MotionEvent.ACTION_MOVE -> { @@ -977,28 +1148,41 @@ class ChatActivity : showRecordAudioUi(true) val movedX: Float = event.x + val movedY: Float = event.y deltaX = movedX - downX + deltaY = movedY - originY + + binding.voiceRecordingLock.translationY.let { + if (it < VOICE_RECORD_LOCK_BUTTON_Y) { + Log.d(TAG, "Voice Recording Locked") + isVoiceRecordingLocked = true + showVoiceRecordingLocked(true) + showVoiceRecordingLockedInterface(true) + } else if (deltaY < 0f) { + binding.voiceRecordingLock.translationY = deltaY + } + } // only allow slide to left - binding.messageInputView.slideToCancelDescription?.x?.let { + binding.messageInputView.slideToCancelDescription.x.let { if (sliderInitX == 0.0F) { sliderInitX = it } if (it > sliderInitX) { - binding.messageInputView.slideToCancelDescription?.x = sliderInitX + binding.messageInputView.slideToCancelDescription.x = sliderInitX } } - binding.messageInputView.slideToCancelDescription?.x?.let { + binding.messageInputView.slideToCancelDescription.x.let { if (it < VOICE_RECORD_CANCEL_SLIDER_X) { Log.d(TAG, "stopping recording because slider was moved to left") stopAndDiscardAudioRecording() - showRecordAudioUi(false) - binding.messageInputView.slideToCancelDescription?.x = sliderInitX + endVoiceRecordingUI() + binding.messageInputView.slideToCancelDescription.x = sliderInitX return true } else { - binding.messageInputView.slideToCancelDescription?.x = it + deltaX + binding.messageInputView.slideToCancelDescription.x = it + deltaX downX = movedX } } @@ -1010,6 +1194,161 @@ class ChatActivity : }) } + private fun initPreviewVoiceRecording() { + voicePreviewMediaPlayer = MediaPlayer().apply { + Log.e(TAG, currentVoiceRecordFile) + setDataSource(currentVoiceRecordFile) + prepare() + setOnPreparedListener { + Log.d(TAG, "Julius the duration is ${it.duration}") + binding.messageInputView.seekBar.progress = 0 + binding.messageInputView.seekBar.max = it.duration + voicePreviewObjectAnimator = ObjectAnimator.ofInt( + binding.messageInputView.seekBar, + "progress", + 0, + it.duration + ).apply { + duration = it.duration.toLong() + interpolator = LinearInterpolator() + } + voicePreviewMediaPlayer!!.start() + voicePreviewObjectAnimator!!.start() + } + + setOnCompletionListener { + stopPreviewVoicePlaying() + } + } + } + + private fun startPreviewVoicePlaying() { + Log.d(TAG, "started preview voice recording") + if (voicePreviewMediaPlayer == null) { + initPreviewVoiceRecording() + } else { + voicePreviewMediaPlayer!!.start() + voicePreviewObjectAnimator!!.resume() + } + } + + private fun pausePreviewVoicePlaying() { + Log.d(TAG, "paused preview voice recording") + voicePreviewMediaPlayer!!.pause() + voicePreviewObjectAnimator!!.pause() + } + + private fun stopPreviewVoicePlaying() { + if (voicePreviewMediaPlayer != null) { + isVoicePreviewPlaying = false + binding.messageInputView.playPauseBtn.icon = ContextCompat.getDrawable(context, R.drawable.ic_refresh) + voicePreviewObjectAnimator!!.end() + voicePreviewObjectAnimator = null + binding.messageInputView.seekBar.clearAnimation() + voicePreviewMediaPlayer!!.stop() + voicePreviewMediaPlayer!!.release() + voicePreviewMediaPlayer = null + } + } + + private fun restartAudio() { + recorder = MediaRecorder().apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setOutputFile(currentVoiceRecordFile) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioSamplingRate(VOICE_MESSAGE_SAMPLING_RATE) + setAudioEncodingBitRate(VOICE_MESSAGE_ENCODING_BIT_RATE) + setAudioChannels(VOICE_MESSAGE_CHANNELS) + prepare() + start() + } + } + + private fun endVoiceRecordingUI() { + stopPreviewVoicePlaying() + showRecordAudioUi(false) + binding.voiceRecordingLock.translationY = 0f + isVoiceRecordingLocked = false + showVoiceRecordingLocked(false) + showVoiceRecordingLockedInterface(false) + } + + private fun showVoiceRecordingLocked(value: Boolean) { + if (value) { + binding.voiceRecordingLock.setImageDrawable( + ContextCompat.getDrawable(context, R.drawable.ic_lock_grey600_24px) + ) + + binding.voiceRecordingLock.alpha = 1f + binding.voiceRecordingLock.animate().alpha(0f).setDuration(VOICE_RECORDING_LOCK_ANIMATION_DURATION.toLong()) + .setInterpolator(AccelerateInterpolator()).start() + } else { + binding.voiceRecordingLock.setImageDrawable( + ContextCompat.getDrawable(context, R.drawable.ic_lock_open_grey600_24dp) + ) + binding.voiceRecordingLock.alpha = 1f + } + } + + private fun showVoiceRecordingLockedInterface(value: Boolean) { + val audioDurationLayoutParams: LayoutParams = binding.messageInputView.audioRecordDuration + .layoutParams as LayoutParams + + val micInputCloudLayoutParams: LayoutParams = binding.messageInputView.micInputCloud + .layoutParams as LayoutParams + + val deleteVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.deleteVoiceRecording + .layoutParams as LayoutParams + + val sendVoiceRecordingLayoutParams: LayoutParams = binding.messageInputView.sendVoiceRecording + .layoutParams as LayoutParams + + val standardQuarterMargin = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + resources.getDimension(R.dimen.standard_quarter_margin), + resources + .displayMetrics + ).toInt() + + binding.messageInputView.button.isEnabled = true + if (value) { + binding.messageInputView.slideToCancelDescription.visibility = View.GONE + binding.messageInputView.deleteVoiceRecording.visibility = View.VISIBLE + binding.messageInputView.sendVoiceRecording.visibility = View.VISIBLE + binding.messageInputView.micInputCloud.visibility = View.VISIBLE + binding.messageInputView.recordAudioButton.visibility = View.GONE + binding.messageInputView.microphoneEnabledInfo.clearAnimation() + binding.messageInputView.microphoneEnabledInfo.visibility = View.GONE + binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.GONE + binding.messageInputView.recordAudioButton.visibility = View.GONE + micInputCloudLayoutParams.removeRule(BELOW) + micInputCloudLayoutParams.addRule(BELOW, R.id.audioRecordDuration) + deleteVoiceRecordingLayoutParams.removeRule(BELOW) + deleteVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration) + sendVoiceRecordingLayoutParams.removeRule(BELOW) + sendVoiceRecordingLayoutParams.addRule(BELOW, R.id.audioRecordDuration) + audioDurationLayoutParams.removeRule(RelativeLayout.CENTER_VERTICAL) + audioDurationLayoutParams.removeRule(RelativeLayout.END_OF) + audioDurationLayoutParams.addRule(RelativeLayout.CENTER_HORIZONTAL, R.bool.value_true) + audioDurationLayoutParams.setMargins(0, standardQuarterMargin, 0, 0) + startMicInputRecordingAnimation() + Log.d(TAG, "MicInputRecording Started") + } else { + stopMicInputRecordingAnimation() + binding.messageInputView.deleteVoiceRecording.visibility = View.GONE + binding.messageInputView.micInputCloud.visibility = View.GONE + binding.messageInputView.recordAudioButton.visibility = View.VISIBLE + binding.messageInputView.sendVoiceRecording.visibility = View.GONE + binding.messageInputView.playPauseBtn.visibility = View.GONE + binding.messageInputView.seekBar.visibility = View.GONE + audioDurationLayoutParams.addRule(RelativeLayout.CENTER_VERTICAL, R.bool.value_true) + audioDurationLayoutParams.addRule(RelativeLayout.END_OF, R.id.microphoneEnabledInfo) + audioDurationLayoutParams.removeRule(RelativeLayout.CENTER_HORIZONTAL) + audioDurationLayoutParams.setMargins(0, 0, 0, 0) + } + } + private fun initSmileyKeyboardToggler() { val smileyButton = binding.messageInputView.findViewById(R.id.smileyButton) @@ -1128,7 +1467,7 @@ class ChatActivity : fun updateOwnTypingStatus(typedText: CharSequence) { fun sendStartTypingSignalingMessage() { - for ((sessionId, participant) in webSocketInstance?.getUserMap()!!) { + for ((sessionId, _) in webSocketInstance?.getUserMap()!!) { val ncSignalingMessage = NCSignalingMessage() ncSignalingMessage.to = sessionId ncSignalingMessage.type = TYPING_STARTED_SIGNALING_MESSAGE_TYPE @@ -1172,7 +1511,7 @@ class ChatActivity : typingTimer = null typedWhileTypingTimerIsRunning = false - for ((sessionId, participant) in webSocketInstance?.getUserMap()!!) { + for ((sessionId, _) in webSocketInstance?.getUserMap()!!) { val ncSignalingMessage = NCSignalingMessage() ncSignalingMessage.to = sessionId ncSignalingMessage.type = TYPING_STOPPED_SIGNALING_MESSAGE_TYPE @@ -1452,6 +1791,7 @@ class ChatActivity : try { mediaPlayer?.let { if (it.isPlaying) { + Log.d(TAG, "media player is stopped") it.stop() } } @@ -1546,24 +1886,83 @@ class ChatActivity : private fun showRecordAudioUi(show: Boolean) { if (show) { - binding.messageInputView.microphoneEnabledInfo?.visibility = View.VISIBLE - binding.messageInputView.microphoneEnabledInfoBackground?.visibility = View.VISIBLE - binding.messageInputView.audioRecordDuration?.visibility = View.VISIBLE - binding.messageInputView.slideToCancelDescription?.visibility = View.VISIBLE - binding.messageInputView.attachmentButton?.visibility = View.GONE - binding.messageInputView.smileyButton?.visibility = View.GONE - binding.messageInputView.messageInput?.visibility = View.GONE - binding.messageInputView.messageInput?.hint = "" + binding.messageInputView.microphoneEnabledInfo.visibility = View.VISIBLE + binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.VISIBLE + binding.messageInputView.audioRecordDuration.visibility = View.VISIBLE + binding.messageInputView.slideToCancelDescription.visibility = View.VISIBLE + binding.messageInputView.attachmentButton.visibility = View.GONE + binding.messageInputView.smileyButton.visibility = View.GONE + binding.messageInputView.messageInput.visibility = View.GONE + binding.messageInputView.messageInput.hint = "" + binding.voiceRecordingLock.visibility = View.VISIBLE } else { - binding.messageInputView.microphoneEnabledInfo?.visibility = View.GONE - binding.messageInputView.microphoneEnabledInfoBackground?.visibility = View.GONE - binding.messageInputView.audioRecordDuration?.visibility = View.GONE - binding.messageInputView.slideToCancelDescription?.visibility = View.GONE - binding.messageInputView.attachmentButton?.visibility = View.VISIBLE - binding.messageInputView.smileyButton?.visibility = View.VISIBLE - binding.messageInputView.messageInput?.visibility = View.VISIBLE - binding.messageInputView.messageInput?.hint = + binding.messageInputView.microphoneEnabledInfo.visibility = View.GONE + binding.messageInputView.microphoneEnabledInfoBackground.visibility = View.GONE + binding.messageInputView.audioRecordDuration.visibility = View.GONE + binding.messageInputView.slideToCancelDescription.visibility = View.GONE + binding.messageInputView.attachmentButton.visibility = View.VISIBLE + binding.messageInputView.smileyButton.visibility = View.VISIBLE + binding.messageInputView.messageInput.visibility = View.VISIBLE + binding.messageInputView.messageInput.hint = context.resources?.getString(R.string.nc_hint_enter_a_message) + binding.voiceRecordingLock.visibility = View.GONE + } + } + + private fun startMicInputRecordingAnimation() { + val permissionCheck = ContextCompat.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO + ) + + if (micInputAudioRecordThread == null && permissionCheck == PERMISSION_GRANTED) { + Log.d(TAG, "Mic Animation Started") + micInputAudioRecorder = AudioRecord( + MediaRecorder.AudioSource.MIC, + SAMPLE_RATE, + AudioFormat.CHANNEL_IN_MONO, + AudioFormat.ENCODING_PCM_16BIT, + bufferSize + ) + isMicInputAudioThreadRunning = true + micInputAudioRecorder.startRecording() + micInputAudioRecordThread = Thread( + Runnable { + while (isMicInputAudioThreadRunning) { + val byteArr = ByteArray(bufferSize / 2) + micInputAudioRecorder.read(byteArr, 0, byteArr.size) + val d = Math.abs(byteArr[0].toDouble()) + if (d > AUDIO_VALUE_MAX) { + binding.messageInputView.micInputCloud.setRotationSpeed( + Math.log10(d).toFloat(), + MicInputCloud.MAXIMUM_RADIUS + ) + } else if (d > AUDIO_VALUE_MIN) { + binding.messageInputView.micInputCloud.setRotationSpeed( + Math.log10(d).toFloat(), + MicInputCloud.EXTENDED_RADIUS + ) + } else { + binding.messageInputView.micInputCloud.setRotationSpeed( + 1f, + MicInputCloud.DEFAULT_RADIUS + ) + } + Thread.sleep(AUDIO_VALUE_SLEEP) + } + } + ) + micInputAudioRecordThread!!.start() + } + } + + private fun stopMicInputRecordingAnimation() { + if (micInputAudioRecordThread != null) { + Log.d(TAG, "Mic Animation Ended") + micInputAudioRecorder.stop() + micInputAudioRecorder.release() + isMicInputAudioThreadRunning = false + micInputAudioRecordThread = null } } @@ -1571,19 +1970,19 @@ class ChatActivity : return PermissionChecker.checkSelfPermission( context, Manifest.permission.RECORD_AUDIO - ) == PermissionChecker.PERMISSION_GRANTED + ) == PERMISSION_GRANTED } private fun startAudioRecording(file: String) { - binding.messageInputView.audioRecordDuration?.base = SystemClock.elapsedRealtime() - binding.messageInputView.audioRecordDuration?.start() + binding.messageInputView.audioRecordDuration.base = SystemClock.elapsedRealtime() + binding.messageInputView.audioRecordDuration.start() val animation: Animation = AlphaAnimation(1.0f, 0.0f) animation.duration = ANIMATION_DURATION animation.interpolator = LinearInterpolator() animation.repeatCount = Animation.INFINITE animation.repeatMode = Animation.REVERSE - binding.messageInputView.microphoneEnabledInfo?.startAnimation(animation) + binding.messageInputView.microphoneEnabledInfo.startAnimation(animation) recorder = MediaRecorder().apply { setAudioSource(MediaRecorder.AudioSource.MIC) @@ -1612,13 +2011,18 @@ class ChatActivity : } private fun stopAndSendAudioRecording() { - stopAudioRecording() + if (isVoiceRecordingInProgress) { + stopAudioRecording() + } + val uri = Uri.fromFile(File(currentVoiceRecordFile)) uploadFile(uri.toString(), true) } private fun stopAndDiscardAudioRecording() { - stopAudioRecording() + if (isVoiceRecordingInProgress) { + stopAudioRecording() + } val cachedFile = File(currentVoiceRecordFile) cachedFile.delete() @@ -1626,26 +2030,22 @@ class ChatActivity : @Suppress("Detekt.TooGenericExceptionCaught") private fun stopAudioRecording() { - binding.messageInputView.audioRecordDuration?.stop() - binding.messageInputView.microphoneEnabledInfo?.clearAnimation() + binding.messageInputView.audioRecordDuration.stop() + binding.messageInputView.microphoneEnabledInfo.clearAnimation() - if (isVoiceRecordingInProgress) { - recorder?.apply { - try { - stop() - release() - isVoiceRecordingInProgress = false - Log.d(TAG, "stopped recorder. isVoiceRecordingInProgress = false") - } catch (e: RuntimeException) { - Log.w(TAG, "error while stopping recorder!") - } - - VibrationUtils.vibrateShort(context) + recorder?.apply { + try { + stop() + release() + isVoiceRecordingInProgress = false + Log.d(TAG, "stopped recorder. isVoiceRecordingInProgress = false") + } catch (e: java.lang.IllegalStateException) { + error("error while stopping recorder!" + e) } - recorder = null - } else { - Log.e(TAG, "tried to stop audio recorder but it was not recording") + + VibrationUtils.vibrateShort(context) } + recorder = null } private fun requestRecordAudioPermissions() { @@ -1761,7 +2161,7 @@ class ChatActivity : ConversationUtils.isLobbyViewApplicable(currentConversation!!, conversationUser!!) ) { if (shouldShowLobby()) { - binding.lobby.lobbyView?.visibility = View.VISIBLE + binding.lobby.lobbyView.visibility = View.VISIBLE binding.messagesListView.visibility = View.GONE binding.messageInputView.visibility = View.GONE binding.progressBar.visibility = View.GONE @@ -1785,9 +2185,9 @@ class ChatActivity : } sb.append(currentConversation!!.description) - binding.lobby.lobbyTextView?.text = sb.toString() + binding.lobby.lobbyTextView.text = sb.toString() } else { - binding.lobby.lobbyView?.visibility = View.GONE + binding.lobby.lobbyView.visibility = View.GONE binding.messagesListView.visibility = View.VISIBLE binding.messageInputView.inputEditText?.visibility = View.VISIBLE if (isFirstMessagesProcessing && pastPreconditionFailed) { @@ -1799,7 +2199,7 @@ class ChatActivity : } } } else { - binding.lobby.lobbyView?.visibility = View.GONE + binding.lobby.lobbyView.visibility = View.GONE binding.messagesListView.visibility = View.VISIBLE binding.messageInputView.inputEditText?.visibility = View.VISIBLE } @@ -1937,7 +2337,7 @@ class ChatActivity : filesToUpload.add(videoURI.toString()) videoURI = null } else { - throw IllegalStateException("Failed to get data from intent and uri") + error("Failed to get data from intent and uri") } if (permissionUtil.isFilesPermissionGranted()) { @@ -2248,8 +2648,9 @@ class ChatActivity : if (currentConversation?.displayName != null) { try { " " + EmojiCompat.get().process(currentConversation?.displayName as CharSequence).toString() - } catch (e: IllegalStateException) { + } catch (e: java.lang.IllegalStateException) { " " + currentConversation?.displayName + error(e) } } else { "" @@ -2774,7 +3175,7 @@ class ChatActivity : private fun modifyMessageCount(shouldAddNewMessagesNotice: Boolean, shouldScroll: Boolean) { if (!shouldAddNewMessagesNotice && !shouldScroll) { - binding.popupBubbleView.isShown?.let { + binding.popupBubbleView.isShown.let { if (it) { newMessagesCount++ } else { @@ -3455,11 +3856,11 @@ class ChatActivity : private fun showMicrophoneButton(show: Boolean) { if (show && CapabilitiesUtilNew.hasSpreedFeatureCapability(conversationUser, "voice-message-sharing")) { - binding.messageInputView.messageSendButton?.visibility = View.GONE - binding.messageInputView.recordAudioButton?.visibility = View.VISIBLE + binding.messageInputView.messageSendButton.visibility = View.GONE + binding.messageInputView.recordAudioButton.visibility = View.VISIBLE } else { - binding.messageInputView.messageSendButton?.visibility = View.VISIBLE - binding.messageInputView.recordAudioButton?.visibility = View.GONE + binding.messageInputView.messageSendButton.visibility = View.VISIBLE + binding.messageInputView.recordAudioButton.visibility = View.GONE } } @@ -3710,7 +4111,6 @@ class ChatActivity : private const val CONTENT_TYPE_POLL: Byte = 5 private const val CONTENT_TYPE_LINK_PREVIEW: Byte = 6 private const val NEW_MESSAGES_POPUP_BUBBLE_DELAY: Long = 200 - private const val POP_CURRENT_CONTROLLER_DELAY: Long = 100 private const val GET_ROOM_INFO_DELAY_NORMAL: Long = 30000 private const val GET_ROOM_INFO_DELAY_LOBBY: Long = 5000 private const val HTTP_CODE_OK: Int = 200 @@ -3727,6 +4127,7 @@ class ChatActivity : private const val OBJECT_MESSAGE: String = "{object}" private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000 private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -50 + private const val VOICE_RECORD_LOCK_BUTTON_Y: Int = -130 private const val VOICE_MESSAGE_META_DATA = "{\"messageType\":\"voice-message\"}" private const val VOICE_MESSAGE_FILE_SUFFIX = ".mp3" @@ -3753,11 +4154,14 @@ class ChatActivity : private const val INVITE_LENGTH = 6 private const val ACTOR_LENGTH = 6 private const val ANIMATION_DURATION: Long = 750 - private const val RETRIES: Long = 3 private const val LOOKING_INTO_FUTURE_TIMEOUT = 30 private const val CHUNK_SIZE: Int = 10 private const val ONE_SECOND_IN_MILLIS = 1000 - + private const val SAMPLE_RATE = 8000 + private const val VOICE_RECORDING_LOCK_ANIMATION_DURATION = 500 + private const val AUDIO_VALUE_MAX = 40 + private const val AUDIO_VALUE_MIN = 20 + private const val AUDIO_VALUE_SLEEP: Long = 50 private const val WHITESPACE = " " private const val COMMA = ", " private const val TYPING_INDICATOR_ANIMATION_DURATION = 200L diff --git a/app/src/main/java/com/nextcloud/talk/ui/MessageInput.kt b/app/src/main/java/com/nextcloud/talk/ui/MessageInput.kt index 1ba4fa7ac..5da7243cc 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/MessageInput.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/MessageInput.kt @@ -25,8 +25,10 @@ import android.util.AttributeSet import android.widget.Chronometer import android.widget.ImageButton import android.widget.ImageView +import android.widget.SeekBar import android.widget.TextView import androidx.emoji2.widget.EmojiEditText +import com.google.android.material.button.MaterialButton import com.nextcloud.talk.R import com.stfalcon.chatkit.messages.MessageInput @@ -37,6 +39,11 @@ class MessageInput : MessageInput { lateinit var microphoneEnabledInfo: ImageView lateinit var microphoneEnabledInfoBackground: ImageView lateinit var smileyButton: ImageButton + lateinit var deleteVoiceRecording: ImageView + lateinit var sendVoiceRecording: ImageView + lateinit var micInputCloud: MicInputCloud + lateinit var playPauseBtn: MaterialButton + lateinit var seekBar: SeekBar constructor(context: Context?) : super(context) { init() @@ -57,6 +64,11 @@ class MessageInput : MessageInput { microphoneEnabledInfo = findViewById(R.id.microphoneEnabledInfo) microphoneEnabledInfoBackground = findViewById(R.id.microphoneEnabledInfoBackground) smileyButton = findViewById(R.id.smileyButton) + deleteVoiceRecording = findViewById(R.id.deleteVoiceRecording) + sendVoiceRecording = findViewById(R.id.sendVoiceRecording) + micInputCloud = findViewById(R.id.micInputCloud) + playPauseBtn = findViewById(R.id.playPauseBtn) + seekBar = findViewById(R.id.seekbar) } var messageInput: EmojiEditText diff --git a/app/src/main/java/com/nextcloud/talk/ui/MicInputCloud.kt b/app/src/main/java/com/nextcloud/talk/ui/MicInputCloud.kt new file mode 100644 index 000000000..50e3c9ada --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/ui/MicInputCloud.kt @@ -0,0 +1,391 @@ +/* + * Nextcloud Talk application + * + * @author Julius Linus + * Copyright (C) 2023 Julius Linus + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.nextcloud.talk.ui + +import android.animation.ValueAnimator +import android.content.Context +import android.content.res.Resources +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.Paint.ANTI_ALIAS_FLAG +import android.graphics.Path +import android.graphics.Rect +import android.graphics.drawable.VectorDrawable +import android.util.AttributeSet +import android.view.View +import android.view.animation.LinearInterpolator +import androidx.annotation.ColorInt +import com.nextcloud.talk.R +import kotlin.math.roundToInt + +class MicInputCloud(context: Context, attrs: AttributeSet) : View(context, attrs) { + /** + * State Descriptions: + * - PAUSED_STATE: Animation speed is set to zero. + * - PLAY_STATE: Animation speed is set to default, but can be overridden. + */ + enum class ViewState { + /** + * Animation speed is set to zero. + */ + PAUSED_STATE, + + /** + * Animation speed is set to default, but can be overridden. + */ + PLAY_STATE + } + + @ColorInt + private var primaryColor: Int = Color.WHITE + + private var pauseIcon: VectorDrawable? = null + + private var playIcon: VectorDrawable? = null + + init { + context.theme.obtainStyledAttributes( + attrs, + R.styleable.MicInputCloud, + 0, + 0 + ).apply { + + try { + pauseIcon = getDrawable(R.styleable.MicInputCloud_pauseIcon) as VectorDrawable + playIcon = getDrawable(R.styleable.MicInputCloud_playIcon) as VectorDrawable + } finally { + recycle() + } + } + } + + private var state: ViewState = ViewState.PLAY_STATE + private var ovalOneAnimator: ValueAnimator? = null + private var ovalTwoAnimator: ValueAnimator? = null + private var ovalThreeAnimator: ValueAnimator? = null + private var r1 = OVAL_ONE_DEFAULT_ROTATION + private var r2 = OVAL_TWO_DEFAULT_ROTATION + private var r3 = OVAL_THREE_DEFAULT_ROTATION + private var o1h = OVAL_ONE_DEFAULT_HEIGHT + private var o1w = OVAL_ONE_DEFAULT_WIDTH + private var o2h = OVAL_TWO_DEFAULT_HEIGHT + private var o2w = OVAL_TWO_DEFAULT_WIDTH + private var o3h = OVAL_THREE_DEFAULT_HEIGHT + private var o3w = OVAL_THREE_DEFAULT_WIDTH + private var rotationSpeedMultiplier: Float = DEFAULT_ROTATION_SPEED_MULTIPLIER + private var radius: Float = DEFAULT_RADIUS + private var centerX: Float = 0f + private var centerY: Float = 0f + + private val bottomCirclePaint = Paint(ANTI_ALIAS_FLAG).apply { + color = primaryColor + style = Paint.Style.FILL + alpha = DEFAULT_OPACITY + } + + private val topCircleBounds = Rect(0, 0, 0, 0) + private val iconBounds = topCircleBounds + + override fun onVisibilityChanged(changedView: View, visibility: Int) { + super.onVisibilityChanged(changedView, visibility) + if (visibility == VISIBLE) { + createAnimators() + } else { + state = ViewState.PLAY_STATE + destroyAnimators() + } + } + + private fun createAnimators() { + ovalOneAnimator = ValueAnimator.ofInt( + o1h, + OVAL_ONE_DEFAULT_HEIGHT + ANIMATION_CAP, + o1h + ).apply { + duration = OVAL_ONE_ANIMATION_LENGTH + interpolator = LinearInterpolator() + repeatCount = ValueAnimator.INFINITE + addUpdateListener { valueAnimator -> + o1h = valueAnimator.animatedValue as Int + } + } + + ovalTwoAnimator = ValueAnimator.ofInt( + o2h, + OVAL_TWO_DEFAULT_HEIGHT + ANIMATION_CAP, + o2h + ).apply { + duration = OVAL_TWO_ANIMATION_LENGTH + interpolator = LinearInterpolator() + repeatCount = ValueAnimator.INFINITE + addUpdateListener { valueAnimator -> + o2h = valueAnimator.animatedValue as Int + } + } + + ovalThreeAnimator = ValueAnimator.ofInt( + o3h, + OVAL_THREE_DEFAULT_HEIGHT + ANIMATION_CAP, + o3h + ).apply { + duration = OVAL_THREE_ANIMATION_LENGTH + interpolator = LinearInterpolator() + repeatCount = ValueAnimator.INFINITE + addUpdateListener { valueAnimator -> + o3h = valueAnimator.animatedValue as Int + invalidate() // needed to animate the other listeners as well + } + } + + ovalOneAnimator?.start() + ovalTwoAnimator?.start() + ovalThreeAnimator?.start() + } + + private fun destroyAnimators() { + ovalOneAnimator?.cancel() + ovalOneAnimator?.removeAllUpdateListeners() + ovalTwoAnimator?.cancel() + ovalTwoAnimator?.removeAllUpdateListeners() + ovalThreeAnimator?.cancel() + ovalThreeAnimator?.removeAllUpdateListeners() + } + + private val circlePath: Path = Path() + private val ovalOnePath: Path = Path() + private val ovalTwoPath: Path = Path() + private val ovalThreePath: Path = Path() + + override fun onDraw(canvas: Canvas?) { + super.onDraw(canvas) + circlePath.apply { + addCircle(centerX, centerY, DEFAULT_RADIUS, Path.Direction.CCW) + } + ovalOnePath.apply { + addOval( + centerX - (radius + o1w), + centerY - o1h, + centerX + (radius + o1w), + centerY + o1h, + Path.Direction.CCW + ) + op(this, circlePath, Path.Op.DIFFERENCE) + } + ovalTwoPath.apply { + addOval( + centerX - (radius + o2w), + centerY - o2h, + centerX + (radius + o2w), + centerY + o2h, + Path.Direction.CCW + ) + op(this, circlePath, Path.Op.DIFFERENCE) + } + ovalThreePath.apply { + addOval( + centerX - (radius + o3w), + centerY - o3h, + centerX + (radius + o3w), + centerY + o3h, + Path.Direction.CCW + ) + op(this, circlePath, Path.Op.DIFFERENCE) + } + drawMicInputCloud(canvas) + if (state == ViewState.PLAY_STATE) { + r1 += OVAL_ONE_ANIMATION_SPEED * rotationSpeedMultiplier + r2 -= OVAL_TWO_ANIMATION_SPEED * rotationSpeedMultiplier + r3 += OVAL_THREE_ANIMATION_SPEED * rotationSpeedMultiplier + invalidate() + } + } + + private fun drawMicInputCloud(canvas: Canvas?) { + canvas?.apply { + save() + rotate(r1, centerX, centerY) + drawPath(ovalOnePath, bottomCirclePaint) + restore() + save() + rotate(r2, centerX, centerY) + drawPath(ovalTwoPath, bottomCirclePaint) + restore() + save() + rotate(r3, centerX, centerY) + drawPath(ovalThreePath, bottomCirclePaint) + restore() + circlePath.reset() + ovalOnePath.reset() + ovalTwoPath.reset() + ovalThreePath.reset() + if (state == ViewState.PLAY_STATE) { + pauseIcon?.apply { + bounds = topCircleBounds + setTint(primaryColor) + draw(canvas) + } + } else { + playIcon?.apply { + bounds = topCircleBounds + setTint(primaryColor) + draw(canvas) + } + } + } + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val desiredWidth = DEFAULT_SIZE.dp + val desiredHeight = DEFAULT_SIZE.dp + + val widthMode = MeasureSpec.getMode(widthMeasureSpec) + val widthSize = MeasureSpec.getSize(widthMeasureSpec) + val heightMode = MeasureSpec.getMode(heightMeasureSpec) + val heightSize = MeasureSpec.getSize(heightMeasureSpec) + + val width: Int = when (widthMode) { + MeasureSpec.EXACTLY -> { + widthSize + } + + MeasureSpec.AT_MOST -> { + desiredWidth.coerceAtMost(widthSize) + } + + else -> { + desiredWidth + } + } + + val height: Int = when (heightMode) { + MeasureSpec.EXACTLY -> { + heightSize + } + + MeasureSpec.AT_MOST -> { + desiredHeight.coerceAtMost(heightSize) + } + + else -> { + desiredHeight + } + } + + centerX = (width / 2).toFloat() + centerY = (height / 2).toFloat() + topCircleBounds.apply { + left = (centerX - DEFAULT_RADIUS).toInt() + top = (centerY - DEFAULT_RADIUS).toInt() + right = (centerX + DEFAULT_RADIUS).toInt() + bottom = (centerY + DEFAULT_RADIUS).toInt() + } + + /** + * Drawables are drawn the same way as the canvas is drawn, as both originate from the top-left corner. + * Because of this, the icon's width = (right - left) and height = (bottom - top). + */ + iconBounds.apply { + left = (centerX - DEFAULT_RADIUS + ICON_SIZE.dp).toInt() + top = (centerY - DEFAULT_RADIUS + ICON_SIZE.dp).toInt() + right = (centerX + DEFAULT_RADIUS - ICON_SIZE.dp).toInt() + bottom = (centerY + DEFAULT_RADIUS - ICON_SIZE.dp).toInt() + } + + setMeasuredDimension(width, height) + } + + override fun performClick(): Boolean { + state = if (state == ViewState.PAUSED_STATE) { + ovalOneAnimator?.resume() + ovalTwoAnimator?.resume() + ovalThreeAnimator?.resume() + ViewState.PLAY_STATE + } else { + ovalOneAnimator?.pause() + ovalTwoAnimator?.pause() + ovalThreeAnimator?.pause() + ViewState.PAUSED_STATE + } + invalidate() + return super.performClick() + } + + /** + * Sets the color of the cloud to the parameter, opacity is still set to 50%. + */ + fun setColor(primary: Int) { + primaryColor = primary + bottomCirclePaint.apply { + color = primary + style = Paint.Style.FILL + alpha = DEFAULT_OPACITY + } + invalidate() + } + + /** + * Sets state of the component to the parameter, must be of type MicInputCloud.ViewState. + */ + fun setState(s: ViewState) { + state = s + invalidate() + } + + /** + * Sets the rotation speed and radius to the parameters, defaults are left unchanged. + */ + fun setRotationSpeed(speed: Float, r: Float) { + rotationSpeedMultiplier = speed + radius = r + invalidate() + } + + companion object { + val TAG: String? = MicInputCloud::class.simpleName + const val DEFAULT_RADIUS: Float = 70f + const val EXTENDED_RADIUS: Float = 75f + const val MAXIMUM_RADIUS: Float = 80f + const val ICON_SIZE: Int = 9 // Converted to dp this equals about 24dp + private const val DEFAULT_SIZE: Int = 110 + private const val DEFAULT_OPACITY: Int = 108 + private const val DEFAULT_ROTATION_SPEED_MULTIPLIER: Float = 0.5f + private const val OVAL_ONE_DEFAULT_ROTATION: Float = 105f + private const val OVAL_ONE_DEFAULT_HEIGHT: Int = 85 + private const val OVAL_ONE_DEFAULT_WIDTH: Int = 30 + private const val OVAL_ONE_ANIMATION_LENGTH: Long = 2000 + private const val OVAL_ONE_ANIMATION_SPEED: Float = 2.3f + private const val OVAL_TWO_DEFAULT_ROTATION: Float = 138f + private const val OVAL_TWO_DEFAULT_HEIGHT: Int = 70 + private const val OVAL_TWO_DEFAULT_WIDTH: Int = 25 + private const val OVAL_TWO_ANIMATION_LENGTH: Long = 1000 + private const val OVAL_TWO_ANIMATION_SPEED: Float = 1.75f + private const val OVAL_THREE_DEFAULT_ROTATION: Float = 63f + private const val OVAL_THREE_DEFAULT_HEIGHT: Int = 80 + private const val OVAL_THREE_DEFAULT_WIDTH: Int = 40 + private const val OVAL_THREE_ANIMATION_LENGTH: Long = 1500 + private const val OVAL_THREE_ANIMATION_SPEED: Float = 1f + private const val ANIMATION_CAP: Int = 15 + private val Int.dp: Int + get() = (this * Resources.getSystem().displayMetrics.density).roundToInt() + } +} diff --git a/app/src/main/java/com/nextcloud/talk/ui/theme/TalkSpecificViewThemeUtils.kt b/app/src/main/java/com/nextcloud/talk/ui/theme/TalkSpecificViewThemeUtils.kt index 039012c53..179027f40 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/theme/TalkSpecificViewThemeUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/theme/TalkSpecificViewThemeUtils.kt @@ -46,6 +46,7 @@ import com.nextcloud.android.common.ui.theme.MaterialSchemes import com.nextcloud.android.common.ui.theme.ViewThemeUtilsBase import com.nextcloud.android.common.ui.theme.utils.AndroidXViewThemeUtils import com.nextcloud.talk.R +import com.nextcloud.talk.ui.MicInputCloud import com.nextcloud.talk.utils.DisplayUtils import com.nextcloud.talk.utils.DrawableUtils import com.vanniktech.emoji.EmojiTextView @@ -243,6 +244,12 @@ class TalkSpecificViewThemeUtils @Inject constructor( } } + fun themeMicInputCloud(micInputCloud: MicInputCloud) { + withScheme(micInputCloud) { scheme -> + micInputCloud.setColor(scheme.primary) + } + } + companion object { private val THEMEABLE_PLACEHOLDER_IDS = listOf( R.drawable.ic_mimetype_package_x_generic, diff --git a/app/src/main/res/drawable/baseline_stop_24.xml b/app/src/main/res/drawable/baseline_stop_24.xml new file mode 100644 index 000000000..fbba3e6c8 --- /dev/null +++ b/app/src/main/res/drawable/baseline_stop_24.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 97f2d91ad..cd8576789 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -132,6 +132,19 @@ app:cornerRadius="@dimen/button_corner_radius" app:icon="@drawable/ic_baseline_arrow_downward_24px" /> + + + + + + + + - - + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f91d48c43..38c78a60e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -691,5 +691,6 @@ How to translate with transifex: 8080 1080 This is a test message + Lock recording for continuously recording of the voice message