From 5676226f4229c42a25367c57d2d60bc6b5544484 Mon Sep 17 00:00:00 2001 From: Onuray Sahin Date: Thu, 1 Jul 2021 10:47:41 +0300 Subject: [PATCH] Voice message recording view implementations. --- build.gradle | 2 +- vector/build.gradle | 4 +- .../room/detail/composer/TextComposerView.kt | 31 +-- .../composer/VoiceMessageRecordingHelper.kt | 87 ------- .../detail/timeline/item/MessageVoiceItem.kt | 134 +++++++++++ .../drawable/bg_voice_play_pause_button.xml | 5 + .../main/res/drawable/bg_voice_playback.xml | 12 + ...locked.xml => ic_voice_message_locked.xml} | 0 .../src/main/res/drawable/ic_voice_pause.xml | 12 + .../src/main/res/drawable/ic_voice_play.xml | 9 + .../src/main/res/layout/composer_layout.xml | 16 +- ...composer_layout_constraint_set_compact.xml | 18 +- .../main/res/layout/fragment_room_detail.xml | 8 + .../res/layout/item_timeline_event_base.xml | 7 + .../item_timeline_event_base_noinfo.xml | 2 +- .../layout/item_timeline_event_voice_stub.xml | 87 +++++++ .../layout/view_voice_message_recorder.xml | 225 +++++++++++++++--- vector/src/main/res/values/colors.xml | 13 +- vector/src/main/res/values/strings.xml | 8 + vector/src/main/res/values/theme_dark.xml | 6 + vector/src/main/res/values/theme_light.xml | 6 + 21 files changed, 523 insertions(+), 169 deletions(-) delete mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageRecordingHelper.kt create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt create mode 100644 vector/src/main/res/drawable/bg_voice_play_pause_button.xml create mode 100644 vector/src/main/res/drawable/bg_voice_playback.xml rename vector/src/main/res/drawable/{ic_voice_locked.xml => ic_voice_message_locked.xml} (100%) create mode 100644 vector/src/main/res/drawable/ic_voice_pause.xml create mode 100644 vector/src/main/res/drawable/ic_voice_play.xml create mode 100644 vector/src/main/res/layout/item_timeline_event_voice_stub.xml diff --git a/build.gradle b/build.gradle index a7acc1c124..df412d3fe8 100644 --- a/build.gradle +++ b/build.gradle @@ -50,7 +50,7 @@ allprojects { includeGroupByRegex 'nl\\.dionsegijn' // Voice RecordView - includeGroupByRegex 'com\\.github\\.3llomi' + includeGroupByRegex 'com\\.github\\.Armen101' } } maven { url 'https://oss.sonatype.org/content/repositories/snapshots/' } diff --git a/vector/build.gradle b/vector/build.gradle index 991e483e98..2d662ea242 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -331,7 +331,7 @@ dependencies { implementation "androidx.recyclerview:recyclerview:1.2.1" implementation 'androidx.appcompat:appcompat:1.3.0' implementation "androidx.fragment:fragment-ktx:$fragment_version" - implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation 'androidx.constraintlayout:constraintlayout:2.1.0-beta02' implementation "androidx.sharetarget:sharetarget:1.1.0" implementation 'androidx.core:core-ktx:1.5.0' implementation "androidx.media:media:1.3.1" @@ -393,7 +393,7 @@ dependencies { implementation "androidx.autofill:autofill:$autofill_version" implementation 'jp.wasabeef:glide-transformations:4.3.0' implementation 'com.github.vector-im:PFLockScreen-Android:1.0.0-beta12' - implementation 'com.github.3llomi:RecordView:3.0.1' + implementation 'com.github.Armen101:AudioRecordView:1.0.5' // Custom Tab implementation 'androidx.browser:browser:1.3.0' diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerView.kt index 6672027133..7833864707 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/TextComposerView.kt @@ -20,6 +20,7 @@ import android.content.Context import android.net.Uri import android.text.Editable import android.util.AttributeSet +import android.view.KeyEvent import android.view.ViewGroup import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet @@ -48,9 +49,7 @@ class TextComposerView @JvmOverloads constructor( fun onCloseRelatedMessage() fun onSendMessage(text: CharSequence) fun onAddAttachment() - fun onVoiceRecordingStarted() - fun onVoiceRecordingEnded(recordTime: Long) - fun checkVoiceRecordingPermission(): Boolean + fun onTouchVoiceRecording() } val views: ComposerLayoutBinding @@ -78,7 +77,7 @@ class TextComposerView @JvmOverloads constructor( override fun onTextEmptyStateChanged(isEmpty: Boolean) { val shouldShowSendButton = currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded || !isEmpty views.sendButton.isInvisible = !shouldShowSendButton - views.voiceMessageRecorderView.isVisible = !shouldShowSendButton + callback?.onTextEmptyStateChanged(isEmpty) } } views.composerRelatedMessageCloseButton.setOnClickListener { @@ -94,28 +93,6 @@ class TextComposerView @JvmOverloads constructor( views.attachmentButton.setOnClickListener { callback?.onAddAttachment() } - - views.voiceMessageRecorderView.callback = object : VoiceMessageRecorderView.Callback { - override fun onVoiceRecordingStarted() { - views.attachmentButton.isVisible = false - views.composerEditText.isVisible = false - views.composerEmojiButton.isVisible = false - views.composerEditTextOuterBorder.isVisible = false - callback?.onVoiceRecordingStarted() - } - - override fun onVoiceRecordingEnded(recordTime: Long) { - views.attachmentButton.isVisible = true - views.composerEditText.isVisible = true - views.composerEmojiButton.isVisible = true - views.composerEditTextOuterBorder.isVisible = true - callback?.onVoiceRecordingEnded(recordTime) - } - - override fun checkVoiceRecordingPermission(): Boolean { - return callback?.checkVoiceRecordingPermission().orFalse() - } - } } fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) { @@ -128,7 +105,6 @@ class TextComposerView @JvmOverloads constructor( val shouldShowSendButton = !views.composerEditText.text.isNullOrEmpty() views.sendButton.isInvisible = !shouldShowSendButton - views.voiceMessageRecorderView.isVisible = !shouldShowSendButton } fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) { @@ -139,7 +115,6 @@ class TextComposerView @JvmOverloads constructor( currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded applyNewConstraintSet(animate, transitionComplete) views.sendButton.isInvisible = false - views.voiceMessageRecorderView.isVisible = false } private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageRecordingHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageRecordingHelper.kt deleted file mode 100644 index 63cbbe6e79..0000000000 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/VoiceMessageRecordingHelper.kt +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package im.vector.app.features.home.room.detail.composer - -import android.content.Context -import android.media.MediaRecorder -import androidx.core.content.FileProvider -import androidx.core.net.toUri -import im.vector.app.BuildConfig -import im.vector.lib.multipicker.entity.MultiPickerAudioType -import im.vector.lib.multipicker.utils.toMultiPickerAudioType -import timber.log.Timber -import java.io.File -import java.io.FileOutputStream -import java.lang.RuntimeException -import java.util.UUID -import javax.inject.Inject - -/** - * Helper class to record audio for voice messages. - */ -class VoiceMessageRecordingHelper @Inject constructor( - private val context: Context -) { - - private lateinit var mediaRecorder: MediaRecorder - private val outputDirectory = File(context.cacheDir, "downloads") - private var outputFile: File? = null - - init { - if (!outputDirectory.exists()) { - outputDirectory.mkdirs() - } - } - - private fun refreshMediaRecorder() { - mediaRecorder = MediaRecorder() - mediaRecorder.setAudioSource(MediaRecorder.AudioSource.DEFAULT) - mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.OGG) - mediaRecorder.setAudioEncoder(MediaRecorder.AudioEncoder.OPUS) - mediaRecorder.setAudioEncodingBitRate(24000) - mediaRecorder.setAudioSamplingRate(48000) - } - - fun startRecording() { - outputFile = File(outputDirectory, UUID.randomUUID().toString() + ".ogg") - FileOutputStream(outputFile).use { fos -> - refreshMediaRecorder() - mediaRecorder.setOutputFile(fos.fd) - mediaRecorder.prepare() - mediaRecorder.start() - } - } - - fun stopRecording(recordTime: Long): MultiPickerAudioType? { - try { - mediaRecorder.stop() - mediaRecorder.reset() - mediaRecorder.release() - outputFile?.let { - val outputFileUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileProvider", it) - return outputFileUri?.toMultiPickerAudioType(context) - } ?: return null - } catch (e: RuntimeException) { // Usually thrown when the record is less than 1 second. - Timber.e(e, "Voice message is not valid. Record time: %s", recordTime) - return null - } - } - - fun deleteRecording() { - outputFile?.delete() - } -} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt new file mode 100644 index 0000000000..cfca70840a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt @@ -0,0 +1,134 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.home.room.detail.timeline.item + +import android.text.format.DateUtils +import android.view.ViewGroup +import android.widget.ImageButton +import android.widget.TextView +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import com.visualizer.amplitude.AudioRecordView +import im.vector.app.R +import im.vector.app.core.epoxy.ClickListener +import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder +import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder +import im.vector.app.features.home.room.detail.timeline.helper.VoiceMessagePlaybackTracker + +@EpoxyModelClass(layout = R.layout.item_timeline_event_base) +abstract class MessageVoiceItem : AbsMessageItem() { + + @EpoxyAttribute + var mxcUrl: String = "" + + @EpoxyAttribute + var duration: Int = 0 + + @EpoxyAttribute + var waveform: List = emptyList() + + @EpoxyAttribute + var izLocalFile = false + + @EpoxyAttribute + var izDownloaded = false + + @EpoxyAttribute + lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder + + @EpoxyAttribute + lateinit var contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + var playbackControlButtonClickListener: ClickListener? = null + + @EpoxyAttribute + lateinit var voiceMessagePlaybackTracker: VoiceMessagePlaybackTracker + + + override fun bind(holder: Holder) { + super.bind(holder) + renderSendState(holder.voiceLayout, null) + if (!attributes.informationData.sendState.hasFailed()) { + contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, izLocalFile, holder.progressLayout) + } else { + holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_cross) + holder.progressLayout.isVisible = false + } + + holder.voicePlaybackTime.text = formatPlaybackTime(duration) + + holder.voicePlaybackWaveform.post { + holder.voicePlaybackWaveform.recreate() + waveform.forEach { amplitude -> + holder.voicePlaybackWaveform.update(amplitude) + } + } + + holder.voicePlaybackControlButton.setOnClickListener { playbackControlButtonClickListener?.invoke(it) } + + voiceMessagePlaybackTracker.track(attributes.informationData.eventId, object : VoiceMessagePlaybackTracker.Listener { + override fun onUpdate(state: VoiceMessagePlaybackTracker.Listener.State) { + when (state) { + is VoiceMessagePlaybackTracker.Listener.State.Idle -> handleIdleState(holder, state) + is VoiceMessagePlaybackTracker.Listener.State.Playing -> handlePlayingState(holder, state) + } + } + }) + } + + private fun handleIdleState(holder: Holder, state: VoiceMessagePlaybackTracker.Listener.State.Idle) { + holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_voice_play) + if (state.playbackTime > 0) { + holder.voicePlaybackTime.text = formatPlaybackTime(state.playbackTime) + } else { + holder.voicePlaybackTime.text = formatPlaybackTime(duration) + } + } + + private fun handlePlayingState(holder: Holder, state: VoiceMessagePlaybackTracker.Listener.State.Playing) { + holder.voicePlaybackControlButton.setImageResource(R.drawable.ic_voice_pause) + if (state.playbackTime > 0) { + holder.voicePlaybackTime.text = formatPlaybackTime(state.playbackTime) + } else { + holder.voicePlaybackTime.text = formatPlaybackTime(duration) + } + } + + private fun formatPlaybackTime(time: Int) = DateUtils.formatElapsedTime((time / 1000).toLong()) + + override fun unbind(holder: Holder) { + super.unbind(holder) + contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId) + contentDownloadStateTrackerBinder.unbind(mxcUrl) + } + + override fun getViewType() = STUB_ID + + class Holder : AbsMessageItem.Holder(STUB_ID) { + val voiceLayout by bind(R.id.voiceLayout) + val voicePlaybackControlButton by bind(R.id.voicePlaybackControlButton) + val voicePlaybackTime by bind(R.id.voicePlaybackTime) + val voicePlaybackWaveform by bind(R.id.voicePlaybackWaveform) + val progressLayout by bind(R.id.messageFileUploadProgressLayout) + } + + companion object { + private const val STUB_ID = R.id.messageContentVoiceStub + } +} diff --git a/vector/src/main/res/drawable/bg_voice_play_pause_button.xml b/vector/src/main/res/drawable/bg_voice_play_pause_button.xml new file mode 100644 index 0000000000..c0b14c77e9 --- /dev/null +++ b/vector/src/main/res/drawable/bg_voice_play_pause_button.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/bg_voice_playback.xml b/vector/src/main/res/drawable/bg_voice_playback.xml new file mode 100644 index 0000000000..db31e29bc7 --- /dev/null +++ b/vector/src/main/res/drawable/bg_voice_playback.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/drawable/ic_voice_locked.xml b/vector/src/main/res/drawable/ic_voice_message_locked.xml similarity index 100% rename from vector/src/main/res/drawable/ic_voice_locked.xml rename to vector/src/main/res/drawable/ic_voice_message_locked.xml diff --git a/vector/src/main/res/drawable/ic_voice_pause.xml b/vector/src/main/res/drawable/ic_voice_pause.xml new file mode 100644 index 0000000000..af74dec46c --- /dev/null +++ b/vector/src/main/res/drawable/ic_voice_pause.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_voice_play.xml b/vector/src/main/res/drawable/ic_voice_play.xml new file mode 100644 index 0000000000..ad90006799 --- /dev/null +++ b/vector/src/main/res/drawable/ic_voice_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/layout/composer_layout.xml b/vector/src/main/res/layout/composer_layout.xml index 7c9c23645d..5e40ab275e 100644 --- a/vector/src/main/res/layout/composer_layout.xml +++ b/vector/src/main/res/layout/composer_layout.xml @@ -131,10 +131,16 @@ android:src="@drawable/ic_send" tools:ignore="MissingConstraints" /> - + diff --git a/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml b/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml index b51f69302a..e429cf7d16 100644 --- a/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml +++ b/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml @@ -178,12 +178,18 @@ tools:ignore="MissingPrefix" tools:visibility="visible" /> - + app:layout_constraintEnd_toEndOf="parent" /> + --> \ No newline at end of file diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml index ae7494894c..6a5cae4452 100644 --- a/vector/src/main/res/layout/fragment_room_detail.xml +++ b/vector/src/main/res/layout/fragment_room_detail.xml @@ -248,4 +248,12 @@ android:background="?vctr_chat_effect_snow_background" android:visibility="invisible" /> + + diff --git a/vector/src/main/res/layout/item_timeline_event_base.xml b/vector/src/main/res/layout/item_timeline_event_base.xml index e4d7aa7d9f..f753bc18a9 100644 --- a/vector/src/main/res/layout/item_timeline_event_base.xml +++ b/vector/src/main/res/layout/item_timeline_event_base.xml @@ -132,6 +132,13 @@ android:layout_marginEnd="56dp" android:layout="@layout/item_timeline_event_option_buttons_stub" /> + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/layout/view_voice_message_recorder.xml b/vector/src/main/res/layout/view_voice_message_recorder.xml index 3b124ae7ef..bf48ee1513 100644 --- a/vector/src/main/res/layout/view_voice_message_recorder.xml +++ b/vector/src/main/res/layout/view_voice_message_recorder.xml @@ -2,68 +2,217 @@ + android:layout_height="match_parent"> + app:layout_constraintTop_toBottomOf="@id/voiceMessageMicButton" /> - - - + + + + + + + + + + + tools:ignore="ContentDescription" /> - + tools:visibility="visible" + android:layout_marginBottom="4dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@id/voiceMessageMicButton" + app:layout_constraintStart_toStartOf="parent" > + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/values/colors.xml b/vector/src/main/res/values/colors.xml index f66476a795..f756b4f243 100644 --- a/vector/src/main/res/values/colors.xml +++ b/vector/src/main/res/values/colors.xml @@ -135,6 +135,17 @@ #FFF3F8FD #22252B - #22252B + + + #FFE3E8F0 + #FF394049 + + + @android:color/white + #FF8E99A4 + + + #FFE3E8F0 + #FF394049 diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index b2519f60b2..0dbb65a138 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -2605,6 +2605,7 @@ Video. Image. Audio + Voice File Sticker Poll @@ -3403,4 +3404,11 @@ Start Voice Message Slide to cancel Voice Message Lock + Play Voice Message + Pause Voice Message + Recording voice message + Delete recorded voice message + Release to send + %1$ds left + Tap on the waveform to stop and playback diff --git a/vector/src/main/res/values/theme_dark.xml b/vector/src/main/res/values/theme_dark.xml index 040e73501a..385388d3d4 100644 --- a/vector/src/main/res/values/theme_dark.xml +++ b/vector/src/main/res/values/theme_dark.xml @@ -144,6 +144,12 @@ @style/WidgetButtonSocialLogin.Gitlab.Dark @style/ActionModeTheme + + + @color/vctr_voice_message_lock_background_dark + @color/vctr_voice_message_playback_background_dark + @color/vctr_voice_message_play_pause_button_background_dark + @color/vctr_voice_message_recording_playback_background_dark