From 6a88c61d1253e6b7f3d4de9c54da789213c6c896 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 21 Oct 2022 14:49:29 +0200 Subject: [PATCH 01/41] Group voice broadcast controller buttons in a Flow --- ...e_event_voice_broadcast_listening_stub.xml | 21 +++++++++---------- ...e_event_voice_broadcast_recording_stub.xml | 21 ++++++++++--------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml index 248c04a2f6..97f15967e1 100644 --- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml @@ -118,6 +118,15 @@ app:barrierMargin="12dp" app:constraint_referenced_ids="roomAvatarImageView,titleText,broadcasterViewGroup,voiceBroadcastViewGroup" /> + + - - + android:indeterminateTint="?vctr_content_secondary" /> diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml index e3bb85138d..7b45a194e8 100644 --- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_recording_stub.xml @@ -64,6 +64,15 @@ app:barrierMargin="12dp" app:constraint_referenced_ids="roomAvatarImageView,titleText" /> + + + android:src="@drawable/ic_recording_dot" /> + android:src="@drawable/ic_stop" /> From 1566adb66992894f2713a06a73f5340aca19ead2 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Mon, 24 Oct 2022 14:10:51 +0200 Subject: [PATCH 02/41] Timeline - Add abstraction on voice broadcast items --- .../src/main/res/values/donottranslate.xml | 1 + ...stylable_voice_broadcast_metadata_view.xml | 9 ++ .../factory/VoiceBroadcastItemFactory.kt | 4 + .../item/AbsMessageVoiceBroadcastItem.kt | 96 ++++++++++++++++ .../MessageVoiceBroadcastListeningItem.kt | 104 +++--------------- .../MessageVoiceBroadcastRecordingItem.kt | 54 ++------- .../voicebroadcast/VoiceBroadcastPlayer.kt | 29 +++-- .../views/VoiceBroadcastMetadataView.kt | 66 +++++++++++ vector/src/main/res/drawable/ic_timer.xml | 9 ++ .../res/drawable/ic_voice_broadcast_16.xml | 21 ---- .../res/drawable/ic_voice_broadcast_mic.xml | 12 ++ ...e_event_voice_broadcast_listening_stub.xml | 77 +++++-------- ...e_event_voice_broadcast_recording_stub.xml | 36 +++++- .../layout/view_voice_broadcast_metadata.xml | 27 +++++ 14 files changed, 329 insertions(+), 216 deletions(-) create mode 100644 library/ui-styles/src/main/res/values/stylable_voice_broadcast_metadata_view.xml create mode 100644 vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt create mode 100644 vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt create mode 100644 vector/src/main/res/drawable/ic_timer.xml delete mode 100644 vector/src/main/res/drawable/ic_voice_broadcast_16.xml create mode 100644 vector/src/main/res/drawable/ic_voice_broadcast_mic.xml create mode 100644 vector/src/main/res/layout/view_voice_broadcast_metadata.xml diff --git a/library/ui-strings/src/main/res/values/donottranslate.xml b/library/ui-strings/src/main/res/values/donottranslate.xml index 741d23dbc6..bfe751ef5a 100755 --- a/library/ui-strings/src/main/res/values/donottranslate.xml +++ b/library/ui-strings/src/main/res/values/donottranslate.xml @@ -2,6 +2,7 @@ + Not implemented yet in ${app_name} diff --git a/library/ui-styles/src/main/res/values/stylable_voice_broadcast_metadata_view.xml b/library/ui-styles/src/main/res/values/stylable_voice_broadcast_metadata_view.xml new file mode 100644 index 0000000000..1f72eeb396 --- /dev/null +++ b/library/ui-styles/src/main/res/values/stylable_voice_broadcast_metadata_view.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt index 5dc601a91a..7b8c927186 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt @@ -67,6 +67,7 @@ class VoiceBroadcastItemFactory @Inject constructor( createRecordingItem( params.event.roomId, eventsGroup.groupId, + mostRecentMessageContent.voiceBroadcastState, highlight, callback, attributes @@ -87,6 +88,7 @@ class VoiceBroadcastItemFactory @Inject constructor( private fun createRecordingItem( roomId: String, voiceBroadcastId: String, + voiceBroadcastState: VoiceBroadcastState?, highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, @@ -100,6 +102,8 @@ class VoiceBroadcastItemFactory @Inject constructor( .colorProvider(colorProvider) .drawableProvider(drawableProvider) .voiceBroadcastRecorder(voiceBroadcastRecorder) + .voiceBroadcastId(voiceBroadcastId) + .voiceBroadcastState(voiceBroadcastState) .leftGuideline(avatarSizeProvider.leftGuideline) .callback(callback) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt new file mode 100644 index 0000000000..cbf35e89d2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2022 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.widget.ImageView +import android.widget.TextView +import androidx.annotation.IdRes +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import im.vector.app.R +import im.vector.app.core.extensions.tintBackground +import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.resources.DrawableProvider +import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import org.matrix.android.sdk.api.util.MatrixItem + +abstract class AbsMessageVoiceBroadcastItem : AbsMessageItem() { + + @EpoxyAttribute + var callback: TimelineEventController.Callback? = null + + @EpoxyAttribute + lateinit var colorProvider: ColorProvider + + @EpoxyAttribute + lateinit var drawableProvider: DrawableProvider + + @EpoxyAttribute + lateinit var voiceBroadcastId: String + + @EpoxyAttribute + var voiceBroadcastState: VoiceBroadcastState? = null + + @EpoxyAttribute + var roomItem: MatrixItem? = null + + override fun isCacheable(): Boolean = false + + override fun bind(holder: H) { + super.bind(holder) + renderHeader(holder) + } + + private fun renderHeader(holder: H) { + with(holder) { + roomItem?.let { + attributes.avatarRenderer.render(it, roomAvatarImageView) + titleText.text = it.displayName + } + } + renderLiveIcon(holder) + renderMetadata(holder) + } + + private fun renderLiveIcon(holder: H) { + with(holder) { + when (voiceBroadcastState) { + VoiceBroadcastState.STARTED, + VoiceBroadcastState.RESUMED -> { + liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError)) + liveIndicator.isVisible = true + } + VoiceBroadcastState.PAUSED -> { + liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary)) + liveIndicator.isVisible = true + } + VoiceBroadcastState.STOPPED, null -> { + liveIndicator.isVisible = false + } + } + } + } + + abstract fun renderMetadata(holder: H) + + abstract class Holder(@IdRes stubId: Int) : AbsMessageItem.Holder(stubId) { + val liveIndicator by bind(R.id.liveIndicator) + val roomAvatarImageView by bind(R.id.roomAvatarImageView) + val titleText by bind(R.id.titleText) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt index 5b58dda4e6..135053d9a9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt @@ -18,56 +18,26 @@ package im.vector.app.features.home.room.detail.timeline.item import android.view.View import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.onClick -import im.vector.app.core.extensions.tintBackground -import im.vector.app.core.resources.ColorProvider -import im.vector.app.core.resources.DrawableProvider import im.vector.app.features.home.room.detail.RoomDetailAction -import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.voicebroadcast.VoiceBroadcastPlayer -import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState -import org.matrix.android.sdk.api.util.MatrixItem +import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView @EpoxyModelClass -abstract class MessageVoiceBroadcastListeningItem : AbsMessageItem() { - - @EpoxyAttribute - var callback: TimelineEventController.Callback? = null +abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem() { @EpoxyAttribute var voiceBroadcastPlayer: VoiceBroadcastPlayer? = null - @EpoxyAttribute - lateinit var voiceBroadcastId: String - - @EpoxyAttribute - var voiceBroadcastState: VoiceBroadcastState? = null - @EpoxyAttribute var broadcasterName: String? = null - @EpoxyAttribute - lateinit var colorProvider: ColorProvider - - @EpoxyAttribute - lateinit var drawableProvider: DrawableProvider - - @EpoxyAttribute - var roomItem: MatrixItem? = null - - @EpoxyAttribute - var title: String? = null - private lateinit var playerListener: VoiceBroadcastPlayer.Listener - override fun isCacheable(): Boolean = false - override fun bind(holder: Holder) { super.bind(holder) bindVoiceBroadcastItem(holder) @@ -75,51 +45,20 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageItem - renderState(holder, state) + renderPlayingState(holder, state) } - voiceBroadcastPlayer?.addListener(playerListener) - renderHeader(holder) - renderLiveIcon(holder) + voiceBroadcastPlayer?.addListener(voiceBroadcastId, playerListener) } - private fun renderHeader(holder: Holder) { + override fun renderMetadata(holder: Holder) { with(holder) { - roomItem?.let { - attributes.avatarRenderer.render(it, roomAvatarImageView) - titleText.text = it.displayName - } - broadcasterNameText.text = broadcasterName + broadcasterNameMetadata.value = broadcasterName.orEmpty() + voiceBroadcastMetadata.isVisible = true + listenersCountMetadata.isVisible = false } } - private fun renderLiveIcon(holder: Holder) { - with(holder) { - when (voiceBroadcastState) { - VoiceBroadcastState.STARTED, - VoiceBroadcastState.RESUMED -> { - liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError)) - liveIndicator.isVisible = true - } - VoiceBroadcastState.PAUSED -> { - liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary)) - liveIndicator.isVisible = true - } - VoiceBroadcastState.STOPPED, null -> { - liveIndicator.isVisible = false - } - } - } - } - - private fun renderState(holder: Holder, state: VoiceBroadcastPlayer.State) { - if (isCurrentMediaActive()) { - renderActiveMedia(holder, state) - } else { - renderInactiveMedia(holder) - } - } - - private fun renderActiveMedia(holder: Holder, state: VoiceBroadcastPlayer.State) { + private fun renderPlayingState(holder: Holder, state: VoiceBroadcastPlayer.State) { with(holder) { bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING playPauseButton.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING @@ -143,34 +82,19 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageItem(R.id.liveIndicator) - val roomAvatarImageView by bind(R.id.roomAvatarImageView) - val titleText by bind(R.id.titleText) + class Holder : AbsMessageVoiceBroadcastItem.Holder(STUB_ID) { val playPauseButton by bind(R.id.playPauseButton) val bufferingView by bind(R.id.bufferingView) - val broadcasterNameText by bind(R.id.broadcasterNameText) + val broadcasterNameMetadata by bind(R.id.broadcasterNameMetadata) + val voiceBroadcastMetadata by bind(R.id.voiceBroadcastMetadata) + val listenersCountMetadata by bind(R.id.listenersCountMetadata) } companion object { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt index c417053b2a..b766698851 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt @@ -17,46 +17,23 @@ package im.vector.app.features.home.room.detail.timeline.item import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.onClick -import im.vector.app.core.extensions.tintBackground -import im.vector.app.core.resources.ColorProvider -import im.vector.app.core.resources.DrawableProvider import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction -import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder -import org.matrix.android.sdk.api.util.MatrixItem +import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView @EpoxyModelClass -abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem() { - - @EpoxyAttribute - var callback: TimelineEventController.Callback? = null +abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem() { @EpoxyAttribute var voiceBroadcastRecorder: VoiceBroadcastRecorder? = null - @EpoxyAttribute - lateinit var colorProvider: ColorProvider - - @EpoxyAttribute - lateinit var drawableProvider: DrawableProvider - - @EpoxyAttribute - var roomItem: MatrixItem? = null - - @EpoxyAttribute - var title: String? = null - private lateinit var recorderListener: VoiceBroadcastRecorder.Listener - override fun isCacheable(): Boolean = false - override fun bind(holder: Holder) { super.bind(holder) bindVoiceBroadcastItem(holder) @@ -65,32 +42,26 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem { stopRecordButton.isEnabled = true recordButton.isEnabled = true - liveIndicator.isVisible = true - liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError)) - val drawableColor = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary) val drawable = drawableProvider.getDrawable(R.drawable.ic_play_pause_pause, drawableColor) recordButton.setImageDrawable(drawable) @@ -102,9 +73,6 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem { recordButton.isEnabled = false stopRecordButton.isEnabled = false - liveIndicator.isVisible = false } } } @@ -126,10 +93,9 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem(R.id.liveIndicator) - val roomAvatarImageView by bind(R.id.roomAvatarImageView) - val titleText by bind(R.id.titleText) + class Holder : AbsMessageVoiceBroadcastItem.Holder(STUB_ID) { + val listenersCountMetadata by bind(R.id.listenersCountMetadata) + val remainingTimeMetadata by bind(R.id.remainingTimeMetadata) val recordButton by bind(R.id.recordButton) val stopRecordButton by bind(R.id.stopRecordButton) } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt index 2c892c8306..6545948021 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt @@ -82,10 +82,17 @@ class VoiceBroadcastPlayer @Inject constructor( set(value) { Timber.w("## VoiceBroadcastPlayer state: $field -> $value") field = value - listeners.forEach { it.onStateChanged(value) } + // Notify state change to all the listeners attached to the current voice broadcast id + currentVoiceBroadcastId?.let { voiceBroadcastId -> + listeners[voiceBroadcastId]?.forEach { listener -> listener.onStateChanged(value) } + } } private var currentRoomId: String? = null - private var listeners = CopyOnWriteArrayList() + + /** + * Map voiceBroadcastId to listeners + */ + private var listeners: MutableMap> = mutableMapOf() fun playOrResume(roomId: String, eventId: String) { val hasChanged = currentVoiceBroadcastId != eventId @@ -133,13 +140,21 @@ class VoiceBroadcastPlayer @Inject constructor( currentVoiceBroadcastId = null } - fun addListener(listener: Listener) { - listeners.add(listener) - listener.onStateChanged(state) + /** + * Add a [Listener] to the given voice broadcast id. + */ + fun addListener(voiceBroadcastId: String, listener: Listener) { + listeners[voiceBroadcastId]?.add(listener) ?: run { + listeners[voiceBroadcastId] = CopyOnWriteArrayList().apply { add(listener) } + } + if (voiceBroadcastId == currentVoiceBroadcastId) listener.onStateChanged(state) else listener.onStateChanged(State.IDLE) } - fun removeListener(listener: Listener) { - listeners.remove(listener) + /** + * Remove a [Listener] from the given voice broadcast id. + */ + fun removeListener(voiceBroadcastId: String, listener: Listener) { + listeners[voiceBroadcastId]?.remove(listener) } private fun startPlayback(roomId: String, eventId: String) { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt new file mode 100644 index 0000000000..e142cb15ce --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 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.voicebroadcast.views + +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.core.content.res.use +import im.vector.app.R +import im.vector.app.databinding.ViewVoiceBroadcastMetadataBinding + +class VoiceBroadcastMetadataView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val views = ViewVoiceBroadcastMetadataBinding.inflate( + LayoutInflater.from(context), + this + ) + + var value: String + get() = views.metadataValue.text.toString() + set(newValue) { + views.metadataValue.text = newValue + } + + init { + context.obtainStyledAttributes( + attrs, + R.styleable.VoiceBroadcastMetadataView, + 0, + 0 + ).use { + setIcon(it) + setValue(it) + } + } + + private fun setIcon(typedArray: TypedArray) { + val icon = typedArray.getDrawable(R.styleable.VoiceBroadcastMetadataView_metadataIcon) + views.metadataIcon.setImageDrawable(icon) + } + + private fun setValue(typedArray: TypedArray) { + val value = typedArray.getString(R.styleable.VoiceBroadcastMetadataView_metadataValue) + views.metadataValue.text = value + } +} diff --git a/vector/src/main/res/drawable/ic_timer.xml b/vector/src/main/res/drawable/ic_timer.xml new file mode 100644 index 0000000000..11a42b0696 --- /dev/null +++ b/vector/src/main/res/drawable/ic_timer.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_voice_broadcast_16.xml b/vector/src/main/res/drawable/ic_voice_broadcast_16.xml deleted file mode 100644 index 7d427a56d0..0000000000 --- a/vector/src/main/res/drawable/ic_voice_broadcast_16.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - diff --git a/vector/src/main/res/drawable/ic_voice_broadcast_mic.xml b/vector/src/main/res/drawable/ic_voice_broadcast_mic.xml new file mode 100644 index 0000000000..edadb55b81 --- /dev/null +++ b/vector/src/main/res/drawable/ic_voice_broadcast_mic.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml index 97f15967e1..16a5b17d68 100644 --- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml @@ -7,8 +7,7 @@ android:layout_height="wrap_content" android:background="@drawable/rounded_rect_shape_8" android:backgroundTint="?vctr_content_quinary" - android:padding="@dimen/layout_vertical_margin" - tools:viewBindingIgnore="true"> + android:padding="@dimen/layout_vertical_margin"> @@ -54,61 +53,41 @@ android:contentDescription="@string/avatar" app:layout_constraintStart_toEndOf="@id/avatarRightBarrier" app:layout_constraintTop_toTopOf="parent" - tools:src="@sample/rooms.json/data/name" /> + tools:text="@sample/rooms.json/data/name" /> - + app:layout_constraintTop_toBottomOf="@id/titleText" /> - - - - - - + app:metadataIcon="@drawable/ic_voice_broadcast_mic" + tools:metadataValue="@sample/users.json/data/displayName" /> - + - - + + app:constraint_referenced_ids="roomAvatarImageView,titleText,metadataFlow" /> + android:padding="@dimen/layout_vertical_margin"> @@ -54,7 +53,34 @@ android:contentDescription="@string/avatar" app:layout_constraintStart_toEndOf="@id/avatarRightBarrier" app:layout_constraintTop_toTopOf="parent" - tools:src="@sample/users.json/data/displayName" /> + tools:text="@sample/users.json/data/displayName" /> + + + + + + + app:constraint_referenced_ids="roomAvatarImageView,titleText,metadataFlow" /> + + + + + + From 4defc3dded84ac411544e5d8f8f8173becbc4509 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Mon, 24 Oct 2022 14:50:56 +0200 Subject: [PATCH 03/41] Voice Broadcast - Add style for the "live" indicator --- .../res/values/styles_voice_broadcast.xml | 19 +++++++++++++++++ .../main/res/drawable/ic_voice_broadcast.xml | 21 +++++++++++++++++++ ...e_event_voice_broadcast_listening_stub.xml | 16 +++----------- ...e_event_voice_broadcast_recording_stub.xml | 14 ++----------- .../layout/view_voice_broadcast_metadata.xml | 2 +- 5 files changed, 46 insertions(+), 26 deletions(-) create mode 100644 library/ui-styles/src/main/res/values/styles_voice_broadcast.xml create mode 100644 vector/src/main/res/drawable/ic_voice_broadcast.xml diff --git a/library/ui-styles/src/main/res/values/styles_voice_broadcast.xml b/library/ui-styles/src/main/res/values/styles_voice_broadcast.xml new file mode 100644 index 0000000000..eb85378141 --- /dev/null +++ b/library/ui-styles/src/main/res/values/styles_voice_broadcast.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/vector/src/main/res/drawable/ic_voice_broadcast.xml b/vector/src/main/res/drawable/ic_voice_broadcast.xml new file mode 100644 index 0000000000..7d427a56d0 --- /dev/null +++ b/vector/src/main/res/drawable/ic_voice_broadcast.xml @@ -0,0 +1,21 @@ + + + + + + + diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml index 16a5b17d68..d508569cb0 100644 --- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml @@ -11,20 +11,10 @@ @@ -78,7 +68,7 @@ android:id="@+id/voiceBroadcastMetadata" android:layout_width="wrap_content" android:layout_height="wrap_content" - app:metadataIcon="@drawable/ic_attachment_voice_broadcast" + app:metadataIcon="@drawable/ic_voice_broadcast" app:metadataValue="@string/attachment_type_voice_broadcast" /> diff --git a/vector/src/main/res/layout/view_voice_broadcast_metadata.xml b/vector/src/main/res/layout/view_voice_broadcast_metadata.xml index 4f0c584d5c..3bc31cd9a0 100644 --- a/vector/src/main/res/layout/view_voice_broadcast_metadata.xml +++ b/vector/src/main/res/layout/view_voice_broadcast_metadata.xml @@ -15,7 +15,7 @@ android:layout_marginEnd="4dp" android:contentDescription="@null" app:tint="?vctr_content_secondary" - tools:src="@drawable/ic_attachment_voice_broadcast" /> + tools:src="@drawable/ic_voice_broadcast" /> Date: Mon, 24 Oct 2022 16:35:16 +0200 Subject: [PATCH 04/41] Improve VoiceBroadcastItemFactory --- .../factory/VoiceBroadcastItemFactory.kt | 48 +++++++++---------- .../timeline/helper/TimelineEventsGroups.kt | 3 ++ 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt index 7b8c927186..b639a2dbae 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt @@ -15,14 +15,14 @@ */ package im.vector.app.features.home.room.detail.timeline.factory -import im.vector.app.core.epoxy.VectorEpoxyHolder -import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider +import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.VoiceBroadcastEventsGroup import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem +import im.vector.app.features.home.room.detail.timeline.item.AbsMessageVoiceBroadcastItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastListeningItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastListeningItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastRecordingItem @@ -34,7 +34,7 @@ import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.getRoom -import org.matrix.android.sdk.api.session.getUser +import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject @@ -53,31 +53,31 @@ class VoiceBroadcastItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, - ): VectorEpoxyModel? { + ): AbsMessageVoiceBroadcastItem<*>? { // Only display item of the initial event with updated data if (messageContent.voiceBroadcastState != VoiceBroadcastState.STARTED) return null - val eventsGroup = params.eventsGroup ?: return null - val voiceBroadcastEventsGroup = VoiceBroadcastEventsGroup(eventsGroup) - val mostRecentTimelineEvent = voiceBroadcastEventsGroup.getLastDisplayableEvent() - val mostRecentEvent = mostRecentTimelineEvent.root.asVoiceBroadcastEvent() - val mostRecentMessageContent = mostRecentEvent?.content ?: return null - val isRecording = mostRecentMessageContent.voiceBroadcastState != VoiceBroadcastState.STOPPED && mostRecentEvent.root.stateKey == session.myUserId - val recorderName = mostRecentTimelineEvent.root.stateKey?.let { session.getUser(it) }?.displayName ?: mostRecentTimelineEvent.root.stateKey + + val voiceBroadcastEventsGroup = params.eventsGroup?.let { VoiceBroadcastEventsGroup(it) } ?: return null + val voiceBroadcastEvent = voiceBroadcastEventsGroup.getLastDisplayableEvent().root.asVoiceBroadcastEvent() ?: return null + val voiceBroadcastContent = voiceBroadcastEvent.content ?: return null + val voiceBroadcastId = voiceBroadcastEventsGroup.voiceBroadcastId + + val isRecording = voiceBroadcastContent.voiceBroadcastState != VoiceBroadcastState.STOPPED && voiceBroadcastEvent.root.stateKey == session.myUserId + return if (isRecording) { createRecordingItem( - params.event.roomId, - eventsGroup.groupId, - mostRecentMessageContent.voiceBroadcastState, + params, + voiceBroadcastId, + voiceBroadcastContent.voiceBroadcastState, highlight, callback, attributes ) } else { createListeningItem( - params.event.roomId, - eventsGroup.groupId, - mostRecentMessageContent.voiceBroadcastState, - recorderName, + params, + voiceBroadcastId, + voiceBroadcastContent.voiceBroadcastState, highlight, callback, attributes @@ -86,14 +86,14 @@ class VoiceBroadcastItemFactory @Inject constructor( } private fun createRecordingItem( - roomId: String, + params: TimelineItemFactoryParams, voiceBroadcastId: String, voiceBroadcastState: VoiceBroadcastState?, highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, ): MessageVoiceBroadcastRecordingItem { - val roomSummary = session.getRoom(roomId)?.roomSummary() + val roomSummary = session.getRoom(params.event.roomId)?.roomSummary() return MessageVoiceBroadcastRecordingItem_() .id("voice_broadcast_$voiceBroadcastId") .attributes(attributes) @@ -109,15 +109,15 @@ class VoiceBroadcastItemFactory @Inject constructor( } private fun createListeningItem( - roomId: String, + params: TimelineItemFactoryParams, voiceBroadcastId: String, voiceBroadcastState: VoiceBroadcastState?, - broadcasterName: String?, highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, ): MessageVoiceBroadcastListeningItem { - val roomSummary = session.getRoom(roomId)?.roomSummary() + val roomSummary = session.getRoom(params.event.roomId)?.roomSummary() + val recorderName = params.event.root.stateKey?.let { session.getUserOrDefault(it) }?.toMatrixItem()?.getBestName() return MessageVoiceBroadcastListeningItem_() .id("voice_broadcast_$voiceBroadcastId") .attributes(attributes) @@ -128,7 +128,7 @@ class VoiceBroadcastItemFactory @Inject constructor( .voiceBroadcastPlayer(voiceBroadcastPlayer) .voiceBroadcastId(voiceBroadcastId) .voiceBroadcastState(voiceBroadcastState) - .broadcasterName(broadcasterName) + .broadcasterName(recorderName) .leftGuideline(avatarSizeProvider.leftGuideline) .callback(callback) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt index d8817c1f44..8a3be7d5f2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt @@ -141,6 +141,9 @@ class CallSignalingEventsGroup(private val group: TimelineEventsGroup) { } class VoiceBroadcastEventsGroup(private val group: TimelineEventsGroup) { + + val voiceBroadcastId = group.groupId + fun getLastDisplayableEvent(): TimelineEvent { return group.events.find { it.root.asVoiceBroadcastEvent()?.content?.voiceBroadcastState == VoiceBroadcastState.STOPPED } ?: group.events.filter { it.root.type == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO }.maxBy { it.root.originServerTs ?: 0L } From 2c144614cabc6427319ebfcb3143ab176b6d565a Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Mon, 24 Oct 2022 16:49:59 +0200 Subject: [PATCH 05/41] Improve recording state rendering if app has been relaunched --- .../MessageVoiceBroadcastRecordingItem.kt | 87 +++++++++++-------- 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt index b766698851..183d2a5577 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt @@ -24,6 +24,7 @@ import im.vector.app.R import im.vector.app.core.epoxy.onClick import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView @EpoxyModelClass @@ -32,7 +33,7 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem @EpoxyAttribute var voiceBroadcastRecorder: VoiceBroadcastRecorder? = null - private lateinit var recorderListener: VoiceBroadcastRecorder.Listener + private var recorderListener: VoiceBroadcastRecorder.Listener? = null override fun bind(holder: Holder) { super.bind(holder) @@ -40,12 +41,15 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem } private fun bindVoiceBroadcastItem(holder: Holder) { - recorderListener = object : VoiceBroadcastRecorder.Listener { - override fun onStateUpdated(state: VoiceBroadcastRecorder.State) { - renderRecordingState(holder, state) - } + if (voiceBroadcastRecorder != null && voiceBroadcastRecorder?.state != VoiceBroadcastRecorder.State.Idle) { + recorderListener = object : VoiceBroadcastRecorder.Listener { + override fun onStateUpdated(state: VoiceBroadcastRecorder.State) { + renderRecordingState(holder, state) + } + }.also { voiceBroadcastRecorder?.addListener(it) } + } else { + renderVoiceBroadcastState(holder) } - voiceBroadcastRecorder?.addListener(recorderListener) } override fun renderMetadata(holder: Holder) { @@ -56,39 +60,54 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem } private fun renderRecordingState(holder: Holder, state: VoiceBroadcastRecorder.State) { - with(holder) { - when (state) { - VoiceBroadcastRecorder.State.Recording -> { - stopRecordButton.isEnabled = true - recordButton.isEnabled = true - - val drawableColor = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary) - val drawable = drawableProvider.getDrawable(R.drawable.ic_play_pause_pause, drawableColor) - recordButton.setImageDrawable(drawable) - recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_pause_voice_broadcast_record) - recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) } - stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } - } - VoiceBroadcastRecorder.State.Paused -> { - stopRecordButton.isEnabled = true - recordButton.isEnabled = true - - recordButton.setImageResource(R.drawable.ic_recording_dot) - recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_resume_voice_broadcast_record) - recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) } - stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } - } - VoiceBroadcastRecorder.State.Idle -> { - recordButton.isEnabled = false - stopRecordButton.isEnabled = false - } - } + when (state) { + VoiceBroadcastRecorder.State.Recording -> renderPlayingState(holder) + VoiceBroadcastRecorder.State.Paused -> renderPausedState(holder) + VoiceBroadcastRecorder.State.Idle -> renderStoppedState(holder) } } + private fun renderVoiceBroadcastState(holder: Holder) { + when (voiceBroadcastState) { + VoiceBroadcastState.STARTED, + VoiceBroadcastState.RESUMED -> renderPlayingState(holder) + VoiceBroadcastState.PAUSED -> renderPausedState(holder) + VoiceBroadcastState.STOPPED, + null -> renderStoppedState(holder) + } + } + + private fun renderPlayingState(holder: Holder) = with(holder) { + stopRecordButton.isEnabled = true + recordButton.isEnabled = true + + val drawableColor = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary) + val drawable = drawableProvider.getDrawable(R.drawable.ic_play_pause_pause, drawableColor) + recordButton.setImageDrawable(drawable) + recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_pause_voice_broadcast_record) + recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) } + stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } + } + + private fun renderPausedState(holder: Holder) = with(holder) { + stopRecordButton.isEnabled = true + recordButton.isEnabled = true + + recordButton.setImageResource(R.drawable.ic_recording_dot) + recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_resume_voice_broadcast_record) + recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) } + stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } + } + + private fun renderStoppedState(holder: Holder) = with(holder) { + recordButton.isEnabled = false + stopRecordButton.isEnabled = false + } + override fun unbind(holder: Holder) { super.unbind(holder) - voiceBroadcastRecorder?.removeListener(recorderListener) + recorderListener?.let { voiceBroadcastRecorder?.removeListener(it) } + recorderListener = null } override fun getViewStubId() = STUB_ID From f31429cf25ca232344715d7a39906472e2e290be Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Tue, 25 Oct 2022 15:22:16 +0200 Subject: [PATCH 06/41] Rename renderLiveIcon method --- .../room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt index cbf35e89d2..afe705ffb6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt @@ -63,11 +63,11 @@ abstract class AbsMessageVoiceBroadcastItem Date: Tue, 25 Oct 2022 16:29:42 +0200 Subject: [PATCH 07/41] Move voice broadcast item attributes to dedicated class --- .../timeline/factory/MessageItemFactory.kt | 2 +- .../factory/VoiceBroadcastItemFactory.kt | 65 ++++++------------- .../item/AbsMessageVoiceBroadcastItem.kt | 42 +++++++----- .../MessageVoiceBroadcastListeningItem.kt | 17 ++--- .../MessageVoiceBroadcastRecordingItem.kt | 18 ++--- 5 files changed, 57 insertions(+), 87 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 245d92f95b..f4d506fa4b 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -201,7 +201,7 @@ class MessageItemFactory @Inject constructor( is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes) is MessageLocationContent -> buildLocationItem(messageContent, informationData, highlight, attributes) is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(params.event, highlight, attributes) - is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(params, messageContent, highlight, callback, attributes) + is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(params, messageContent, highlight, attributes) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } return messageItem?.apply { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt index b639a2dbae..d43ccd9834 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt @@ -18,7 +18,6 @@ package im.vector.app.features.home.room.detail.timeline.factory import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider import im.vector.app.features.displayname.getBestName -import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.VoiceBroadcastEventsGroup import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem @@ -51,7 +50,6 @@ class VoiceBroadcastItemFactory @Inject constructor( params: TimelineItemFactoryParams, messageContent: MessageVoiceBroadcastInfoContent, highlight: Boolean, - callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, ): AbsMessageVoiceBroadcastItem<*>? { // Only display item of the initial event with updated data @@ -64,72 +62,47 @@ class VoiceBroadcastItemFactory @Inject constructor( val isRecording = voiceBroadcastContent.voiceBroadcastState != VoiceBroadcastState.STOPPED && voiceBroadcastEvent.root.stateKey == session.myUserId + val voiceBroadcastAttributes = AbsMessageVoiceBroadcastItem.Attributes( + voiceBroadcastId = voiceBroadcastId, + voiceBroadcastState = voiceBroadcastContent.voiceBroadcastState, + recorderName = params.event.root.stateKey?.let { session.getUserOrDefault(it) }?.toMatrixItem()?.getBestName().orEmpty(), + recorder = voiceBroadcastRecorder, + player = voiceBroadcastPlayer, + roomItem = session.getRoom(params.event.roomId)?.roomSummary()?.toMatrixItem(), + colorProvider = colorProvider, + drawableProvider = drawableProvider, + ) + return if (isRecording) { - createRecordingItem( - params, - voiceBroadcastId, - voiceBroadcastContent.voiceBroadcastState, - highlight, - callback, - attributes - ) + createRecordingItem(highlight, attributes, voiceBroadcastAttributes) } else { - createListeningItem( - params, - voiceBroadcastId, - voiceBroadcastContent.voiceBroadcastState, - highlight, - callback, - attributes - ) + createListeningItem(highlight, attributes, voiceBroadcastAttributes) } } private fun createRecordingItem( - params: TimelineItemFactoryParams, - voiceBroadcastId: String, - voiceBroadcastState: VoiceBroadcastState?, highlight: Boolean, - callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, + voiceBroadcastAttributes: AbsMessageVoiceBroadcastItem.Attributes, ): MessageVoiceBroadcastRecordingItem { - val roomSummary = session.getRoom(params.event.roomId)?.roomSummary() return MessageVoiceBroadcastRecordingItem_() - .id("voice_broadcast_$voiceBroadcastId") + .id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcastId}") .attributes(attributes) + .voiceBroadcastAttributes(voiceBroadcastAttributes) .highlighted(highlight) - .roomItem(roomSummary?.toMatrixItem()) - .colorProvider(colorProvider) - .drawableProvider(drawableProvider) - .voiceBroadcastRecorder(voiceBroadcastRecorder) - .voiceBroadcastId(voiceBroadcastId) - .voiceBroadcastState(voiceBroadcastState) .leftGuideline(avatarSizeProvider.leftGuideline) - .callback(callback) } private fun createListeningItem( - params: TimelineItemFactoryParams, - voiceBroadcastId: String, - voiceBroadcastState: VoiceBroadcastState?, highlight: Boolean, - callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, + voiceBroadcastAttributes: AbsMessageVoiceBroadcastItem.Attributes, ): MessageVoiceBroadcastListeningItem { - val roomSummary = session.getRoom(params.event.roomId)?.roomSummary() - val recorderName = params.event.root.stateKey?.let { session.getUserOrDefault(it) }?.toMatrixItem()?.getBestName() return MessageVoiceBroadcastListeningItem_() - .id("voice_broadcast_$voiceBroadcastId") + .id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcastId}") .attributes(attributes) + .voiceBroadcastAttributes(voiceBroadcastAttributes) .highlighted(highlight) - .roomItem(roomSummary?.toMatrixItem()) - .colorProvider(colorProvider) - .drawableProvider(drawableProvider) - .voiceBroadcastPlayer(voiceBroadcastPlayer) - .voiceBroadcastId(voiceBroadcastId) - .voiceBroadcastState(voiceBroadcastState) - .broadcasterName(recorderName) .leftGuideline(avatarSizeProvider.leftGuideline) - .callback(callback) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt index afe705ffb6..45f10b68d0 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt @@ -25,29 +25,26 @@ import im.vector.app.R import im.vector.app.core.extensions.tintBackground import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider -import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.voicebroadcast.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import org.matrix.android.sdk.api.util.MatrixItem abstract class AbsMessageVoiceBroadcastItem : AbsMessageItem() { @EpoxyAttribute - var callback: TimelineEventController.Callback? = null + lateinit var voiceBroadcastAttributes: Attributes - @EpoxyAttribute - lateinit var colorProvider: ColorProvider - - @EpoxyAttribute - lateinit var drawableProvider: DrawableProvider - - @EpoxyAttribute - lateinit var voiceBroadcastId: String - - @EpoxyAttribute - var voiceBroadcastState: VoiceBroadcastState? = null - - @EpoxyAttribute - var roomItem: MatrixItem? = null + protected val voiceBroadcastId get() = voiceBroadcastAttributes.voiceBroadcastId + protected val voiceBroadcastState get() = voiceBroadcastAttributes.voiceBroadcastState + protected val recorderName get() = voiceBroadcastAttributes.recorderName + protected val recorder get() = voiceBroadcastAttributes.recorder + protected val player get() = voiceBroadcastAttributes.player + protected val roomItem get() = voiceBroadcastAttributes.roomItem + protected val colorProvider get() = voiceBroadcastAttributes.colorProvider + protected val drawableProvider get() = voiceBroadcastAttributes.drawableProvider + protected val avatarRenderer get() = attributes.avatarRenderer + protected val callback get() = attributes.callback override fun isCacheable(): Boolean = false @@ -59,7 +56,7 @@ abstract class AbsMessageVoiceBroadcastItem(R.id.roomAvatarImageView) val titleText by bind(R.id.titleText) } + + data class Attributes( + val voiceBroadcastId: String, + val voiceBroadcastState: VoiceBroadcastState?, + val recorderName: String, + val recorder: VoiceBroadcastRecorder?, + val player: VoiceBroadcastPlayer, + val roomItem: MatrixItem?, + val colorProvider: ColorProvider, + val drawableProvider: DrawableProvider, + ) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt index 135053d9a9..d94bee3672 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt @@ -19,7 +19,6 @@ package im.vector.app.features.home.room.detail.timeline.item import android.view.View import android.widget.ImageButton import androidx.core.view.isVisible -import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.onClick @@ -30,12 +29,6 @@ import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView @EpoxyModelClass abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem() { - @EpoxyAttribute - var voiceBroadcastPlayer: VoiceBroadcastPlayer? = null - - @EpoxyAttribute - var broadcasterName: String? = null - private lateinit var playerListener: VoiceBroadcastPlayer.Listener override fun bind(holder: Holder) { @@ -47,12 +40,12 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem playerListener = VoiceBroadcastPlayer.Listener { state -> renderPlayingState(holder, state) } - voiceBroadcastPlayer?.addListener(voiceBroadcastId, playerListener) + player.addListener(voiceBroadcastId, playerListener) } override fun renderMetadata(holder: Holder) { with(holder) { - broadcasterNameMetadata.value = broadcasterName.orEmpty() + broadcasterNameMetadata.value = recorderName voiceBroadcastMetadata.isVisible = true listenersCountMetadata.isVisible = false } @@ -67,14 +60,14 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem VoiceBroadcastPlayer.State.PLAYING -> { playPauseButton.setImageResource(R.drawable.ic_play_pause_pause) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) - playPauseButton.onClick { attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.Pause) } + playPauseButton.onClick { callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.Pause) } } VoiceBroadcastPlayer.State.IDLE, VoiceBroadcastPlayer.State.PAUSED -> { playPauseButton.setImageResource(R.drawable.ic_play_pause_play) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) playPauseButton.onClick { - attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId)) + callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId)) } } VoiceBroadcastPlayer.State.BUFFERING -> Unit @@ -84,7 +77,7 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem override fun unbind(holder: Holder) { super.unbind(holder) - voiceBroadcastPlayer?.removeListener(voiceBroadcastId, playerListener) + player.removeListener(voiceBroadcastId, playerListener) } override fun getViewStubId() = STUB_ID diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt index 183d2a5577..47e89658ca 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt @@ -18,7 +18,6 @@ package im.vector.app.features.home.room.detail.timeline.item import android.widget.ImageButton import androidx.core.view.isVisible -import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.onClick @@ -30,9 +29,6 @@ import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView @EpoxyModelClass abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem() { - @EpoxyAttribute - var voiceBroadcastRecorder: VoiceBroadcastRecorder? = null - private var recorderListener: VoiceBroadcastRecorder.Listener? = null override fun bind(holder: Holder) { @@ -41,12 +37,12 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem } private fun bindVoiceBroadcastItem(holder: Holder) { - if (voiceBroadcastRecorder != null && voiceBroadcastRecorder?.state != VoiceBroadcastRecorder.State.Idle) { + if (recorder != null && recorder?.state != VoiceBroadcastRecorder.State.Idle) { recorderListener = object : VoiceBroadcastRecorder.Listener { override fun onStateUpdated(state: VoiceBroadcastRecorder.State) { renderRecordingState(holder, state) } - }.also { voiceBroadcastRecorder?.addListener(it) } + }.also { recorder?.addListener(it) } } else { renderVoiceBroadcastState(holder) } @@ -85,8 +81,8 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem val drawable = drawableProvider.getDrawable(R.drawable.ic_play_pause_pause, drawableColor) recordButton.setImageDrawable(drawable) recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_pause_voice_broadcast_record) - recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) } - stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } + recordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) } + stopRecordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } } private fun renderPausedState(holder: Holder) = with(holder) { @@ -95,8 +91,8 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem recordButton.setImageResource(R.drawable.ic_recording_dot) recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_resume_voice_broadcast_record) - recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) } - stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } + recordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) } + stopRecordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } } private fun renderStoppedState(holder: Holder) = with(holder) { @@ -106,7 +102,7 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem override fun unbind(holder: Holder) { super.unbind(holder) - recorderListener?.let { voiceBroadcastRecorder?.removeListener(it) } + recorderListener?.let { recorder?.removeListener(it) } recorderListener = null } From 513097585a7ab9592eb50d5172e30eaf3c428406 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Tue, 25 Oct 2022 17:38:05 +0200 Subject: [PATCH 08/41] Fix kdoc issue --- .../vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt index 6545948021..d8a062c8f8 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt @@ -90,7 +90,7 @@ class VoiceBroadcastPlayer @Inject constructor( private var currentRoomId: String? = null /** - * Map voiceBroadcastId to listeners + * Map voiceBroadcastId to listeners. */ private var listeners: MutableMap> = mutableMapOf() From 0f21f404e694a465cfa82a0da8e178dc9a0af821 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Tue, 25 Oct 2022 17:41:36 +0200 Subject: [PATCH 09/41] Add changelog --- changelog.d/7448.wip | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7448.wip diff --git a/changelog.d/7448.wip b/changelog.d/7448.wip new file mode 100644 index 0000000000..a99e5bbcfa --- /dev/null +++ b/changelog.d/7448.wip @@ -0,0 +1 @@ +[Voice Broadcast] Improve timeline items factory and handle bad recording state display From c7c05d1fe6a293b9233276a8b3d2c68628ecd1e3 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Mon, 24 Oct 2022 16:35:59 +0200 Subject: [PATCH 10/41] Add check on deviceId before showing recording tile --- .../room/detail/timeline/factory/VoiceBroadcastItemFactory.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt index d43ccd9834..7a7cb73471 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt @@ -60,7 +60,9 @@ class VoiceBroadcastItemFactory @Inject constructor( val voiceBroadcastContent = voiceBroadcastEvent.content ?: return null val voiceBroadcastId = voiceBroadcastEventsGroup.voiceBroadcastId - val isRecording = voiceBroadcastContent.voiceBroadcastState != VoiceBroadcastState.STOPPED && voiceBroadcastEvent.root.stateKey == session.myUserId + val isRecording = voiceBroadcastContent.voiceBroadcastState != VoiceBroadcastState.STOPPED && + voiceBroadcastEvent.root.stateKey == session.myUserId && + messageContent.deviceId == session.sessionParams.deviceId val voiceBroadcastAttributes = AbsMessageVoiceBroadcastItem.Attributes( voiceBroadcastId = voiceBroadcastId, From a4eff0cc78d8066ca0fa31a13028b7a81b3c31ed Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Tue, 25 Oct 2022 17:56:27 +0200 Subject: [PATCH 11/41] Add changelog --- changelog.d/7431.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7431.bugfix diff --git a/changelog.d/7431.bugfix b/changelog.d/7431.bugfix new file mode 100644 index 0000000000..681a1e9aa5 --- /dev/null +++ b/changelog.d/7431.bugfix @@ -0,0 +1 @@ + [Voice Broadcast] Do not display the recorder view for a live broadcast started from another session \ No newline at end of file From 6eeb54ae40dbd995a55822659c40365d288e0727 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Mon, 24 Oct 2022 23:59:29 +0200 Subject: [PATCH 12/41] Stop ongoing voice broadcast on app restart --- .../features/home/HomeActivityViewModel.kt | 30 +++++++++++++ .../voicebroadcast/VoiceBroadcastHelper.kt | 4 ++ .../usecase/GetLastVoiceBroadcastUseCase.kt | 45 +++++++++++++++++++ .../usecase/StartVoiceBroadcastUseCase.kt | 10 +---- 4 files changed, 81 insertions(+), 8 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetLastVoiceBroadcastUseCase.kt diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt index 61a8e5b79e..1e79dc5844 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt @@ -42,6 +42,8 @@ import im.vector.app.features.raw.wellknown.isSecureBackupRequired import im.vector.app.features.raw.wellknown.withElementWellKnown import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.lib.core.utils.compat.getParcelableExtraCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -60,12 +62,14 @@ import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse import org.matrix.android.sdk.api.auth.registration.nextUncompletedStage import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes import org.matrix.android.sdk.api.session.crypto.crosssigning.CrossSigningService import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.session.pushrules.RuleIds import org.matrix.android.sdk.api.session.room.model.Membership @@ -92,6 +96,7 @@ class HomeActivityViewModel @AssistedInject constructor( private val analyticsConfig: AnalyticsConfig, private val releaseNotesPreferencesStore: ReleaseNotesPreferencesStore, private val vectorFeatures: VectorFeatures, + private val voiceBroadcastHelper: VoiceBroadcastHelper, ) : VectorViewModel(initialState) { @AssistedFactory @@ -123,6 +128,7 @@ class HomeActivityViewModel @AssistedInject constructor( observeReleaseNotes() observeLocalNotificationsSilenced() initThreadsMigration() + stopOngoingVoiceBroadcast() } private fun observeReleaseNotes() = withState { state -> @@ -490,6 +496,30 @@ class HomeActivityViewModel @AssistedInject constructor( } } + /** + * Stop ongoing voice broadcast if any. + */ + private fun stopOngoingVoiceBroadcast() { + val session = activeSessionHolder.getSafeActiveSession() ?: return + + // FIXME Iterate only on recent rooms for the moment, improve this + val recentRooms = session.roomService().getBreadcrumbs(roomSummaryQueryParams { + displayName = QueryStringValue.NoCondition + memberships = listOf(Membership.JOIN) + }).mapNotNull { session.getRoom(it.roomId) } + + recentRooms + .forEach { room -> + val ongoingVoiceBroadcasts = voiceBroadcastHelper.getOngoingVoiceBroadcasts(room.roomId) + val myOngoingVoiceBroadcastId = ongoingVoiceBroadcasts.find { it.root.stateKey == session.myUserId }?.reference?.eventId + val initialEvent = myOngoingVoiceBroadcastId?.let { room.timelineService().getTimelineEvent(it)?.root?.asVoiceBroadcastEvent() } + if (myOngoingVoiceBroadcastId != null && initialEvent?.content?.deviceId == session.sessionParams.deviceId) { + viewModelScope.launch { voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) } + return // No need to iterate more as we should not have more than one recording VB + } + } + } + override fun handle(action: HomeActivityViewActions) { when (action) { HomeActivityViewActions.PushPromptHasBeenReviewed -> { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt index 58e7de7f32..ee9034661c 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt @@ -16,6 +16,7 @@ package im.vector.app.features.voicebroadcast +import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase import im.vector.app.features.voicebroadcast.usecase.PauseVoiceBroadcastUseCase import im.vector.app.features.voicebroadcast.usecase.ResumeVoiceBroadcastUseCase import im.vector.app.features.voicebroadcast.usecase.StartVoiceBroadcastUseCase @@ -30,6 +31,7 @@ class VoiceBroadcastHelper @Inject constructor( private val pauseVoiceBroadcastUseCase: PauseVoiceBroadcastUseCase, private val resumeVoiceBroadcastUseCase: ResumeVoiceBroadcastUseCase, private val stopVoiceBroadcastUseCase: StopVoiceBroadcastUseCase, + private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase, private val voiceBroadcastPlayer: VoiceBroadcastPlayer, ) { suspend fun startVoiceBroadcast(roomId: String) = startVoiceBroadcastUseCase.execute(roomId) @@ -45,4 +47,6 @@ class VoiceBroadcastHelper @Inject constructor( fun pausePlayback() = voiceBroadcastPlayer.pause() fun stopPlayback() = voiceBroadcastPlayer.stop() + + fun getOngoingVoiceBroadcasts(roomId: String) = getOngoingVoiceBroadcastsUseCase.execute(roomId) } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetLastVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetLastVoiceBroadcastUseCase.kt new file mode 100644 index 0000000000..db2c625161 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetLastVoiceBroadcastUseCase.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2022 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.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.getRoom +import timber.log.Timber +import javax.inject.Inject + +class GetOngoingVoiceBroadcastsUseCase @Inject constructor( + private val session: Session, +) { + + fun execute(roomId: String): List { + val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") + + Timber.d("## GetLastVoiceBroadcastUseCase: get last voice broadcast in $roomId") + + return room.stateService().getStateEvents( + setOf(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO), + QueryStringValue.IsNotEmpty + ) + .mapNotNull { it.asVoiceBroadcastEvent() } + .filter { it.content?.voiceBroadcastState != null && it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt index 7934d18e36..2b7ca7b9f1 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt @@ -25,9 +25,7 @@ import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState -import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.lib.multipicker.utils.toMultiPickerAudioType -import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.toContent @@ -43,6 +41,7 @@ class StartVoiceBroadcastUseCase @Inject constructor( private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, private val context: Context, private val buildMeta: BuildMeta, + private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase, ) { suspend fun execute(roomId: String): Result = runCatching { @@ -50,12 +49,7 @@ class StartVoiceBroadcastUseCase @Inject constructor( Timber.d("## StartVoiceBroadcastUseCase: Start voice broadcast requested") - val onGoingVoiceBroadcastEvents = room.stateService().getStateEvents( - setOf(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO), - QueryStringValue.IsNotEmpty - ) - .mapNotNull { it.asVoiceBroadcastEvent() } - .filter { it.content?.voiceBroadcastState != null && it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } + val onGoingVoiceBroadcastEvents = getOngoingVoiceBroadcastsUseCase.execute(roomId) if (onGoingVoiceBroadcastEvents.isEmpty()) { startVoiceBroadcast(room) From 53db04c8cf28d3b3ce2993367f1df986eccad962 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Tue, 25 Oct 2022 17:58:09 +0200 Subject: [PATCH 13/41] Add changelog --- changelog.d/7450.wip | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7450.wip diff --git a/changelog.d/7450.wip b/changelog.d/7450.wip new file mode 100644 index 0000000000..de4d3dc5e1 --- /dev/null +++ b/changelog.d/7450.wip @@ -0,0 +1 @@ +[Voice Broadcast] Stop recording when opening the room after an app restart From 85bc78bd72f15b2741b02b3a6f8389e0d4474761 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 09:50:58 +0200 Subject: [PATCH 14/41] Do not pause already paused voice broadcast --- .../home/room/detail/composer/MessageComposerFragment.kt | 2 +- .../home/room/detail/composer/MessageComposerViewState.kt | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 55ec922a57..e01dd31516 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -234,7 +234,7 @@ class MessageComposerFragment : VectorBaseFragment(), A } // TODO remove this when there will be a recording indicator outside of the timeline // Pause voice broadcast if the timeline is not shown anymore - it.isVoiceBroadcasting && !requireActivity().isChangingConfigurations -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Pause) + it.isRecordingVoiceBroadcast && !requireActivity().isChangingConfigurations -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Pause) else -> { timelineViewModel.handle(VoiceBroadcastAction.Listening.Pause) messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(composer.text.toString())) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt index 0df1dbebd8..a4021f87b2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt @@ -79,9 +79,8 @@ data class MessageComposerViewState( is VoiceMessageRecorderView.RecordingUiState.Recording -> true } - val isVoiceBroadcasting = when (voiceBroadcastState) { + val isRecordingVoiceBroadcast = when (voiceBroadcastState) { VoiceBroadcastState.STARTED, - VoiceBroadcastState.PAUSED, VoiceBroadcastState.RESUMED -> true else -> false } From 47047b20349c423b6f070e78797ffbbd599d764c Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 10:00:56 +0200 Subject: [PATCH 15/41] move map operator in a new line --- .../vector/app/features/home/HomeActivityViewModel.kt | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt index 1e79dc5844..2c45709291 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt @@ -503,10 +503,12 @@ class HomeActivityViewModel @AssistedInject constructor( val session = activeSessionHolder.getSafeActiveSession() ?: return // FIXME Iterate only on recent rooms for the moment, improve this - val recentRooms = session.roomService().getBreadcrumbs(roomSummaryQueryParams { - displayName = QueryStringValue.NoCondition - memberships = listOf(Membership.JOIN) - }).mapNotNull { session.getRoom(it.roomId) } + val recentRooms = session.roomService() + .getBreadcrumbs(roomSummaryQueryParams { + displayName = QueryStringValue.NoCondition + memberships = listOf(Membership.JOIN) + }) + .mapNotNull { session.getRoom(it.roomId) } recentRooms .forEach { room -> From ec80adc8aa416c07ebd61faff5cab5c26e7ccff4 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 10:10:56 +0200 Subject: [PATCH 16/41] Rename usecase file --- ...iceBroadcastUseCase.kt => GetOngoingVoiceBroadcastsUseCase.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/{GetLastVoiceBroadcastUseCase.kt => GetOngoingVoiceBroadcastsUseCase.kt} (100%) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetLastVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt similarity index 100% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetLastVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt From 6091ec4ce3731b6498c42860d1d82dce677c2a8d Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 10:45:25 +0200 Subject: [PATCH 17/41] Fix wrong content description --- .../timeline/item/MessageVoiceBroadcastListeningItem.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt index d94bee3672..a3e7cc55d5 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt @@ -59,13 +59,13 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem when (state) { VoiceBroadcastPlayer.State.PLAYING -> { playPauseButton.setImageResource(R.drawable.ic_play_pause_pause) - playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) + playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) playPauseButton.onClick { callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.Pause) } } VoiceBroadcastPlayer.State.IDLE, VoiceBroadcastPlayer.State.PAUSED -> { playPauseButton.setImageResource(R.drawable.ic_play_pause_play) - playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) + playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) playPauseButton.onClick { callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId)) } From 8fe3b5e75077d21b32cf6c2ba82fc7c9e47200d7 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 10:46:33 +0200 Subject: [PATCH 18/41] Rename method renderPlayingState to renderRecordingState --- .../timeline/item/MessageVoiceBroadcastRecordingItem.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt index 47e89658ca..e3e86f38e3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt @@ -57,7 +57,7 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem private fun renderRecordingState(holder: Holder, state: VoiceBroadcastRecorder.State) { when (state) { - VoiceBroadcastRecorder.State.Recording -> renderPlayingState(holder) + VoiceBroadcastRecorder.State.Recording -> renderRecordingState(holder) VoiceBroadcastRecorder.State.Paused -> renderPausedState(holder) VoiceBroadcastRecorder.State.Idle -> renderStoppedState(holder) } @@ -66,14 +66,14 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem private fun renderVoiceBroadcastState(holder: Holder) { when (voiceBroadcastState) { VoiceBroadcastState.STARTED, - VoiceBroadcastState.RESUMED -> renderPlayingState(holder) + VoiceBroadcastState.RESUMED -> renderRecordingState(holder) VoiceBroadcastState.PAUSED -> renderPausedState(holder) VoiceBroadcastState.STOPPED, null -> renderStoppedState(holder) } } - private fun renderPlayingState(holder: Holder) = with(holder) { + private fun renderRecordingState(holder: Holder) = with(holder) { stopRecordButton.isEnabled = true recordButton.isEnabled = true From 1554d79f1a57095f3f98b20ffee8e944e7d60374 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 10:48:11 +0200 Subject: [PATCH 19/41] Change listeners Map variable to immutable --- .../vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt index d8a062c8f8..5a04904f69 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt @@ -92,7 +92,7 @@ class VoiceBroadcastPlayer @Inject constructor( /** * Map voiceBroadcastId to listeners. */ - private var listeners: MutableMap> = mutableMapOf() + private val listeners: MutableMap> = mutableMapOf() fun playOrResume(roomId: String, eventId: String) { val hasChanged = currentVoiceBroadcastId != eventId From 2f14d191302b581a37fc6cd3cc5846d1009530b9 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 11:26:12 +0200 Subject: [PATCH 20/41] Fix failing test --- .../usecase/StartVoiceBroadcastUseCaseTest.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt index 9fa6b7a450..f95ab2053b 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt @@ -49,10 +49,11 @@ class StartVoiceBroadcastUseCaseTest { private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) private val fakeVoiceBroadcastRecorder = mockk(relaxed = true) private val startVoiceBroadcastUseCase = StartVoiceBroadcastUseCase( - fakeSession, - fakeVoiceBroadcastRecorder, - FakeContext().instance, - mockk() + session = fakeSession, + voiceBroadcastRecorder = fakeVoiceBroadcastRecorder, + context = FakeContext().instance, + buildMeta = mockk(), + getOngoingVoiceBroadcastsUseCase = GetOngoingVoiceBroadcastsUseCase(fakeSession), ) @Test From 5855fe1242d7e99317b79435a541bf2bd68980ef Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 12:44:18 +0200 Subject: [PATCH 21/41] Add StopOngoingVoiceBroadcastUseCase --- .../features/home/HomeActivityViewModel.kt | 35 +---------- .../StopOngoingVoiceBroadcastUseCase.kt | 63 +++++++++++++++++++ 2 files changed, 66 insertions(+), 32 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopOngoingVoiceBroadcastUseCase.kt diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt index 2c45709291..c3abdde022 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt @@ -42,8 +42,7 @@ import im.vector.app.features.raw.wellknown.isSecureBackupRequired import im.vector.app.features.raw.wellknown.withElementWellKnown import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorPreferences -import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper -import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.usecase.StopOngoingVoiceBroadcastUseCase import im.vector.lib.core.utils.compat.getParcelableExtraCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -62,14 +61,12 @@ import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse import org.matrix.android.sdk.api.auth.registration.nextUncompletedStage import org.matrix.android.sdk.api.extensions.tryOrNull -import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes import org.matrix.android.sdk.api.session.crypto.crosssigning.CrossSigningService import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap import org.matrix.android.sdk.api.session.events.model.toModel -import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.session.pushrules.RuleIds import org.matrix.android.sdk.api.session.room.model.Membership @@ -96,7 +93,7 @@ class HomeActivityViewModel @AssistedInject constructor( private val analyticsConfig: AnalyticsConfig, private val releaseNotesPreferencesStore: ReleaseNotesPreferencesStore, private val vectorFeatures: VectorFeatures, - private val voiceBroadcastHelper: VoiceBroadcastHelper, + private val stopOngoingVoiceBroadcastUseCase: StopOngoingVoiceBroadcastUseCase, ) : VectorViewModel(initialState) { @AssistedFactory @@ -128,7 +125,7 @@ class HomeActivityViewModel @AssistedInject constructor( observeReleaseNotes() observeLocalNotificationsSilenced() initThreadsMigration() - stopOngoingVoiceBroadcast() + viewModelScope.launch { stopOngoingVoiceBroadcastUseCase.execute() } } private fun observeReleaseNotes() = withState { state -> @@ -496,32 +493,6 @@ class HomeActivityViewModel @AssistedInject constructor( } } - /** - * Stop ongoing voice broadcast if any. - */ - private fun stopOngoingVoiceBroadcast() { - val session = activeSessionHolder.getSafeActiveSession() ?: return - - // FIXME Iterate only on recent rooms for the moment, improve this - val recentRooms = session.roomService() - .getBreadcrumbs(roomSummaryQueryParams { - displayName = QueryStringValue.NoCondition - memberships = listOf(Membership.JOIN) - }) - .mapNotNull { session.getRoom(it.roomId) } - - recentRooms - .forEach { room -> - val ongoingVoiceBroadcasts = voiceBroadcastHelper.getOngoingVoiceBroadcasts(room.roomId) - val myOngoingVoiceBroadcastId = ongoingVoiceBroadcasts.find { it.root.stateKey == session.myUserId }?.reference?.eventId - val initialEvent = myOngoingVoiceBroadcastId?.let { room.timelineService().getTimelineEvent(it)?.root?.asVoiceBroadcastEvent() } - if (myOngoingVoiceBroadcastId != null && initialEvent?.content?.deviceId == session.sessionParams.deviceId) { - viewModelScope.launch { voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) } - return // No need to iterate more as we should not have more than one recording VB - } - } - } - override fun handle(action: HomeActivityViewActions) { when (action) { HomeActivityViewActions.PushPromptHasBeenReviewed -> { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopOngoingVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopOngoingVoiceBroadcastUseCase.kt new file mode 100644 index 0000000000..82baa5e6a8 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopOngoingVoiceBroadcastUseCase.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2022 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.voicebroadcast.usecase + +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams +import timber.log.Timber +import javax.inject.Inject + +/** + * Stop ongoing voice broadcast if any. + */ +class StopOngoingVoiceBroadcastUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val voiceBroadcastHelper: VoiceBroadcastHelper, +) { + + suspend fun execute() { + Timber.d("## StopOngoingVoiceBroadcastUseCase: Stop ongoing voice broadcast requested") + + val session = activeSessionHolder.getSafeActiveSession() ?: run { + Timber.w("## StopOngoingVoiceBroadcastUseCase: no active session") + return + } + // FIXME Iterate only on recent rooms for the moment, improve this + val recentRooms = session.roomService() + .getBreadcrumbs(roomSummaryQueryParams { + displayName = QueryStringValue.NoCondition + memberships = listOf(Membership.JOIN) + }) + .mapNotNull { session.getRoom(it.roomId) } + + recentRooms + .forEach { room -> + val ongoingVoiceBroadcasts = voiceBroadcastHelper.getOngoingVoiceBroadcasts(room.roomId) + val myOngoingVoiceBroadcastId = ongoingVoiceBroadcasts.find { it.root.stateKey == session.myUserId }?.reference?.eventId + val initialEvent = myOngoingVoiceBroadcastId?.let { room.timelineService().getTimelineEvent(it)?.root?.asVoiceBroadcastEvent() } + if (myOngoingVoiceBroadcastId != null && initialEvent?.content?.deviceId == session.sessionParams.deviceId) { + voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) + return // No need to iterate more as we should not have more than one recording VB + } + } + } +} From 443d573205bec14e5a7f12d0220da0a659194f4c Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 12:48:32 +0200 Subject: [PATCH 22/41] Remove getOngoingVoiceBroadcasts from VoiceBroadcastHelper --- .../app/features/voicebroadcast/VoiceBroadcastHelper.kt | 4 ---- .../usecase/StopOngoingVoiceBroadcastUseCase.kt | 3 ++- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt index ee9034661c..58e7de7f32 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt @@ -16,7 +16,6 @@ package im.vector.app.features.voicebroadcast -import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase import im.vector.app.features.voicebroadcast.usecase.PauseVoiceBroadcastUseCase import im.vector.app.features.voicebroadcast.usecase.ResumeVoiceBroadcastUseCase import im.vector.app.features.voicebroadcast.usecase.StartVoiceBroadcastUseCase @@ -31,7 +30,6 @@ class VoiceBroadcastHelper @Inject constructor( private val pauseVoiceBroadcastUseCase: PauseVoiceBroadcastUseCase, private val resumeVoiceBroadcastUseCase: ResumeVoiceBroadcastUseCase, private val stopVoiceBroadcastUseCase: StopVoiceBroadcastUseCase, - private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase, private val voiceBroadcastPlayer: VoiceBroadcastPlayer, ) { suspend fun startVoiceBroadcast(roomId: String) = startVoiceBroadcastUseCase.execute(roomId) @@ -47,6 +45,4 @@ class VoiceBroadcastHelper @Inject constructor( fun pausePlayback() = voiceBroadcastPlayer.pause() fun stopPlayback() = voiceBroadcastPlayer.stop() - - fun getOngoingVoiceBroadcasts(roomId: String) = getOngoingVoiceBroadcastsUseCase.execute(roomId) } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopOngoingVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopOngoingVoiceBroadcastUseCase.kt index 82baa5e6a8..ab4d16ab60 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopOngoingVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopOngoingVoiceBroadcastUseCase.kt @@ -31,6 +31,7 @@ import javax.inject.Inject */ class StopOngoingVoiceBroadcastUseCase @Inject constructor( private val activeSessionHolder: ActiveSessionHolder, + private val getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase, private val voiceBroadcastHelper: VoiceBroadcastHelper, ) { @@ -51,7 +52,7 @@ class StopOngoingVoiceBroadcastUseCase @Inject constructor( recentRooms .forEach { room -> - val ongoingVoiceBroadcasts = voiceBroadcastHelper.getOngoingVoiceBroadcasts(room.roomId) + val ongoingVoiceBroadcasts = getOngoingVoiceBroadcastsUseCase.execute(room.roomId) val myOngoingVoiceBroadcastId = ongoingVoiceBroadcasts.find { it.root.stateKey == session.myUserId }?.reference?.eventId val initialEvent = myOngoingVoiceBroadcastId?.let { room.timelineService().getTimelineEvent(it)?.root?.asVoiceBroadcastEvent() } if (myOngoingVoiceBroadcastId != null && initialEvent?.content?.deviceId == session.sessionParams.deviceId) { From 23b4f6d42f467d9257f8aae068941ab566af6afb Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 12:49:51 +0200 Subject: [PATCH 23/41] Inject ActiveSessionHolder in GetOngoingVoiceBroadcastsUseCase --- .../usecase/GetOngoingVoiceBroadcastsUseCase.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt index db2c625161..47a9ed7b4a 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt @@ -16,21 +16,22 @@ package im.vector.app.features.voicebroadcast.usecase +import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import org.matrix.android.sdk.api.query.QueryStringValue -import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.getRoom import timber.log.Timber import javax.inject.Inject class GetOngoingVoiceBroadcastsUseCase @Inject constructor( - private val session: Session, + private val activeSessionHolder: ActiveSessionHolder, ) { fun execute(roomId: String): List { + val session = activeSessionHolder.getSafeActiveSession() ?: return emptyList() val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") Timber.d("## GetLastVoiceBroadcastUseCase: get last voice broadcast in $roomId") From 0cc2a477b473aeaec0183add3dd3243fc2c49a18 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 14:54:55 +0200 Subject: [PATCH 24/41] Mockk GetOngoingVoiceBroadcastsUseCase and adapt tests --- .../usecase/GetOngoingVoiceBroadcastsUseCase.kt | 8 ++++++-- .../usecase/StartVoiceBroadcastUseCaseTest.kt | 16 ++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt index 47a9ed7b4a..cb228ad8aa 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt @@ -31,8 +31,12 @@ class GetOngoingVoiceBroadcastsUseCase @Inject constructor( ) { fun execute(roomId: String): List { - val session = activeSessionHolder.getSafeActiveSession() ?: return emptyList() - val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") + println("## GetOngoingVoiceBroadcastsUseCase") + println("## GetOngoingVoiceBroadcastsUseCase activeSessionHolder $activeSessionHolder") + val session = activeSessionHolder.getSafeActiveSession() + println("## GetOngoingVoiceBroadcastsUseCase session $session") + val room = session?.getRoom(roomId) ?: error("Unknown roomId: $roomId") + println("## GetOngoingVoiceBroadcastsUseCase room $room") Timber.d("## GetLastVoiceBroadcastUseCase: get last voice broadcast in $roomId") diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt index f95ab2053b..217a395076 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt @@ -20,6 +20,7 @@ import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.app.test.fakes.FakeContext import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoomService @@ -27,13 +28,13 @@ import im.vector.app.test.fakes.FakeSession import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import io.mockk.slot import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBeNull import org.junit.Test -import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.toContent @@ -48,12 +49,13 @@ class StartVoiceBroadcastUseCaseTest { private val fakeRoom = FakeRoom() private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) private val fakeVoiceBroadcastRecorder = mockk(relaxed = true) + private val fakeGetOngoingVoiceBroadcastsUseCase = mockk() private val startVoiceBroadcastUseCase = StartVoiceBroadcastUseCase( session = fakeSession, voiceBroadcastRecorder = fakeVoiceBroadcastRecorder, context = FakeContext().instance, buildMeta = mockk(), - getOngoingVoiceBroadcastsUseCase = GetOngoingVoiceBroadcastsUseCase(fakeSession), + getOngoingVoiceBroadcastsUseCase = fakeGetOngoingVoiceBroadcastsUseCase, ) @Test @@ -81,7 +83,7 @@ class StartVoiceBroadcastUseCaseTest { private suspend fun testVoiceBroadcastStarted(voiceBroadcasts: List) { // Given clearAllMocks() - givenAVoiceBroadcasts(voiceBroadcasts) + givenVoiceBroadcasts(voiceBroadcasts) val voiceBroadcastInfoContentInterceptor = slot() coEvery { fakeRoom.stateService().sendStateEvent(any(), any(), capture(voiceBroadcastInfoContentInterceptor)) } coAnswers { AN_EVENT_ID } @@ -104,7 +106,7 @@ class StartVoiceBroadcastUseCaseTest { private suspend fun testVoiceBroadcastNotStarted(voiceBroadcasts: List) { // Given clearAllMocks() - givenAVoiceBroadcasts(voiceBroadcasts) + givenVoiceBroadcasts(voiceBroadcasts) // When startVoiceBroadcastUseCase.execute(A_ROOM_ID) @@ -113,7 +115,7 @@ class StartVoiceBroadcastUseCaseTest { coVerify(exactly = 0) { fakeRoom.stateService().sendStateEvent(any(), any(), any()) } } - private fun givenAVoiceBroadcasts(voiceBroadcasts: List) { + private fun givenVoiceBroadcasts(voiceBroadcasts: List) { val events = voiceBroadcasts.map { Event( type = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, @@ -123,7 +125,9 @@ class StartVoiceBroadcastUseCaseTest { ).toContent() ) } - fakeRoom.stateService().givenGetStateEvents(QueryStringValue.IsNotEmpty, events) + .mapNotNull { it.asVoiceBroadcastEvent() } + .filter { it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } + every { fakeGetOngoingVoiceBroadcastsUseCase.execute(any()) } returns events } private data class VoiceBroadcast(val userId: String, val state: VoiceBroadcastState) From d242ab049b0c5c09ab1ea0f8b3dda2aa805472a0 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Wed, 26 Oct 2022 15:15:48 +0200 Subject: [PATCH 25/41] [Rich text editor] Implement full screen editor mode (simple approach) (#7436) * Rich text editor: implement full screen editor mode using ConstraintSets * Add back press handler * Change ToggleFullScreen to SetFullScreen, fix rebase issues * Add warning to fragment_timeline* files --- changelog.d/7436.feature | 1 + .../src/main/res/values/strings.xml | 1 + vector/src/main/AndroidManifest.xml | 3 +- .../app/core/extensions/ViewExtensions.kt | 21 ++ .../JumpToBottomViewVisibilityManager.kt | 10 +- .../home/room/detail/TimelineFragment.kt | 52 +++- .../detail/composer/MessageComposerAction.kt | 2 + .../composer/MessageComposerFragment.kt | 22 +- .../detail/composer/MessageComposerView.kt | 12 +- .../composer/MessageComposerViewModel.kt | 15 +- .../composer/MessageComposerViewState.kt | 1 + .../composer/PlainTextComposerLayout.kt | 12 +- .../detail/composer/RichTextComposerLayout.kt | 47 ++-- .../res/drawable/ic_composer_full_screen.xml | 9 + .../res/layout/composer_rich_text_layout.xml | 14 +- ...ich_text_layout_constraint_set_compact.xml | 21 +- ...ch_text_layout_constraint_set_expanded.xml | 18 +- ..._text_layout_constraint_set_fullscreen.xml | 217 +++++++++++++++ .../src/main/res/layout/fragment_composer.xml | 4 +- .../src/main/res/layout/fragment_timeline.xml | 18 ++ .../layout/fragment_timeline_fullscreen.xml | 258 ++++++++++++++++++ 21 files changed, 705 insertions(+), 53 deletions(-) create mode 100644 changelog.d/7436.feature create mode 100644 vector/src/main/res/drawable/ic_composer_full_screen.xml create mode 100644 vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml create mode 100644 vector/src/main/res/layout/fragment_timeline_fullscreen.xml diff --git a/changelog.d/7436.feature b/changelog.d/7436.feature new file mode 100644 index 0000000000..b038c975e1 --- /dev/null +++ b/changelog.d/7436.feature @@ -0,0 +1 @@ +Rich text editor: add full screen mode. diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index ea9b4b5999..450dcab1f7 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3423,5 +3423,6 @@ Apply italic format Apply strikethrough format Apply underline format + Toggle full screen mode diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index b0cd202d12..11a54e9f82 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -150,7 +150,8 @@ + android:parentActivityName=".features.home.HomeActivity" + android:windowSoftInputMode="adjustResize"> diff --git a/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt b/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt index 625ff15ef7..156809d5ad 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt @@ -29,7 +29,13 @@ import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.isVisible +import androidx.transition.ChangeBounds +import androidx.transition.Fade +import androidx.transition.Transition +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet import im.vector.app.R +import im.vector.app.core.animations.SimpleTransitionListener import im.vector.app.features.themes.ThemeUtils /** @@ -90,3 +96,18 @@ fun View.setAttributeBackground(@AttrRes attributeId: Int) { val attribute = ThemeUtils.getAttribute(context, attributeId)!! setBackgroundResource(attribute.resourceId) } + +fun ViewGroup.animateLayoutChange(animationDuration: Long, transitionComplete: (() -> Unit)? = null) { + val transition = TransitionSet().apply { + ordering = TransitionSet.ORDERING_SEQUENTIAL + addTransition(ChangeBounds()) + addTransition(Fade(Fade.IN)) + duration = animationDuration + addListener(object : SimpleTransitionListener() { + override fun onTransitionEnd(transition: Transition) { + transitionComplete?.invoke() + } + }) + } + TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt index 0f7dc251ae..1368b71ec6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt @@ -34,6 +34,8 @@ class JumpToBottomViewVisibilityManager( private val layoutManager: LinearLayoutManager ) { + private var canShowButtonOnScroll = true + init { recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { @@ -43,7 +45,7 @@ class JumpToBottomViewVisibilityManager( if (scrollingToPast) { jumpToBottomView.hide() - } else { + } else if (canShowButtonOnScroll) { maybeShowJumpToBottomViewVisibility() } } @@ -66,7 +68,13 @@ class JumpToBottomViewVisibilityManager( } } + fun hideAndPreventVisibilityChangesWithScrolling() { + jumpToBottomView.hide() + canShowButtonOnScroll = false + } + private fun maybeShowJumpToBottomViewVisibility() { + canShowButtonOnScroll = true if (layoutManager.findFirstVisibleItemPosition() > 1) { jumpToBottomView.show() } else { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index 9d50cdb070..4f51922a62 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -32,7 +32,9 @@ import android.view.ViewGroup import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView +import androidx.activity.addCallback import androidx.appcompat.view.menu.MenuBuilder +import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.net.toUri @@ -64,6 +66,7 @@ import im.vector.app.core.dialogs.ConfirmationDialogBuilder import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper import im.vector.app.core.dialogs.GalleryOrCameraDialogHelperFactory import im.vector.app.core.epoxy.LayoutManagerStateRestorer +import im.vector.app.core.extensions.animateLayoutChange import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.containsRtLOverride @@ -183,7 +186,9 @@ import im.vector.app.features.widgets.WidgetArgs import im.vector.app.features.widgets.WidgetKind import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -337,6 +342,7 @@ class TimelineFragment : setupJumpToBottomView() setupRemoveJitsiWidgetView() setupLiveLocationIndicator() + setupBackPressHandling() views.includeRoomToolbar.roomToolbarContentView.debouncedClicks { navigator.openRoomProfile(requireActivity(), timelineArgs.roomId) @@ -414,6 +420,31 @@ class TimelineFragment : if (savedInstanceState == null) { handleSpaceShare() } + + views.scrim.setOnClickListener { + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(false)) + } + + messageComposerViewModel.stateFlow.map { it.isFullScreen } + .distinctUntilChanged() + .onEach { isFullScreen -> + toggleFullScreenEditor(isFullScreen) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + private fun setupBackPressHandling() { + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + withState(messageComposerViewModel) { state -> + if (state.isFullScreen) { + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(false)) + } else { + remove() // Remove callback to avoid infinite loop + @Suppress("DEPRECATION") + requireActivity().onBackPressed() + } + } + } } private fun setupRemoveJitsiWidgetView() { @@ -1016,7 +1047,13 @@ class TimelineFragment : override fun onLayoutCompleted(state: RecyclerView.State) { super.onLayoutCompleted(state) updateJumpToReadMarkerViewVisibility() - jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() + withState(messageComposerViewModel) { composerState -> + if (!composerState.isFullScreen) { + jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() + } else { + jumpToBottomViewVisibilityManager.hideAndPreventVisibilityChangesWithScrolling() + } + } } }.apply { // For local rooms, pin the view's content to the top edge (the layout is reversed) @@ -2002,6 +2039,19 @@ class TimelineFragment : } } + private fun toggleFullScreenEditor(isFullScreen: Boolean) { + views.composerContainer.animateLayoutChange(200) + + val constraintSet = ConstraintSet() + val constraintSetId = if (isFullScreen) { + R.layout.fragment_timeline_fullscreen + } else { + R.layout.fragment_timeline + } + constraintSet.clone(requireContext(), constraintSetId) + constraintSet.applyTo(views.rootConstraintLayout) + } + /** * Returns true if the current room is a Thread room, false otherwise. */ diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index 82adcd014a..30437a016d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt @@ -34,6 +34,8 @@ sealed class MessageComposerAction : VectorViewModelAction { data class SlashCommandConfirmed(val parsedCommand: ParsedCommand) : MessageComposerAction() data class InsertUserDisplayName(val userId: String) : MessageComposerAction() + data class SetFullScreen(val isFullScreen: Boolean) : MessageComposerAction() + // Voice Message data class InitializeVoiceRecorder(val attachmentData: ContentAttachmentData) : MessageComposerAction() data class OnVoiceRecordingUiStateChanged(val uiState: VoiceMessageRecorderView.RecordingUiState) : MessageComposerAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 55ec922a57..beb7215c22 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -92,6 +92,7 @@ import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.share.SharedData import im.vector.app.features.voice.VoiceFailure import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -219,6 +220,13 @@ class MessageComposerFragment : VectorBaseFragment(), A } } + messageComposerViewModel.stateFlow.map { it.isFullScreen } + .distinctUntilChanged() + .onEach { isFullScreen -> + composer.toggleFullScreen(isFullScreen) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + if (savedInstanceState != null) { handleShareData() } @@ -297,7 +305,7 @@ class MessageComposerFragment : VectorBaseFragment(), A // Show keyboard when the user started a thread composerEditText.showKeyboard(andRequestFocus = true) } - composer.callback = object : PlainTextComposerLayout.Callback { + composer.callback = object : Callback { override fun onAddAttachment() { if (!::attachmentTypeSelector.isInitialized) { attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@MessageComposerFragment) @@ -320,8 +328,12 @@ class MessageComposerFragment : VectorBaseFragment(), A composer.emojiButton?.isVisible = isEmojiKeyboardVisible } - override fun onSendMessage(text: CharSequence) { + override fun onSendMessage(text: CharSequence) = withState(messageComposerViewModel) { state -> sendTextMessage(text, composer.formattedText) + + if (state.isFullScreen) { + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(false)) + } } override fun onCloseRelatedMessage() { @@ -335,6 +347,10 @@ class MessageComposerFragment : VectorBaseFragment(), A override fun onTextChanged(text: CharSequence) { messageComposerViewModel.handle(MessageComposerAction.OnTextChanged(text)) } + + override fun onFullScreenModeChanged() = withState(messageComposerViewModel) { state -> + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(!state.isFullScreen)) + } } } @@ -461,7 +477,7 @@ class MessageComposerFragment : VectorBaseFragment(), A composer.sendButton.alpha = 0f composer.sendButton.isVisible = true composer.sendButton.animate().alpha(1f).setDuration(150).start() - } else { + } else if (!event.isVisible) { composer.sendButton.isInvisible = true } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt index 09357191b4..b7e0e29679 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt @@ -30,13 +30,14 @@ interface MessageComposerView { val emojiButton: ImageButton? val sendButton: ImageButton val attachmentButton: ImageButton + val fullScreenButton: ImageButton? val composerRelatedMessageTitle: TextView val composerRelatedMessageContent: TextView val composerRelatedMessageImage: ImageView val composerRelatedMessageActionIcon: ImageView val composerRelatedMessageAvatar: ImageView - var callback: PlainTextComposerLayout.Callback? + var callback: Callback? var isVisible: Boolean @@ -44,6 +45,15 @@ interface MessageComposerView { fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) fun setTextIfDifferent(text: CharSequence?): Boolean fun replaceFormattedContent(text: CharSequence) + fun toggleFullScreen(newValue: Boolean) fun setInvisible(isInvisible: Boolean) } + +interface Callback : ComposerEditText.Callback { + fun onCloseRelatedMessage() + fun onSendMessage(text: CharSequence) + fun onAddAttachment() + fun onExpandOrCompactChange() + fun onFullScreenModeChanged() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index 1a9f9e6291..23d6e71114 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.composer +import android.text.SpannableString import androidx.lifecycle.asFlow import com.airbnb.mvrx.MavericksViewModelFactory import dagger.assisted.Assisted @@ -122,6 +123,7 @@ class MessageComposerViewModel @AssistedInject constructor( is MessageComposerAction.AudioSeekBarMovedTo -> handleAudioSeekBarMovedTo(action) is MessageComposerAction.SlashCommandConfirmed -> handleSlashCommandConfirmed(action) is MessageComposerAction.InsertUserDisplayName -> handleInsertUserDisplayName(action) + is MessageComposerAction.SetFullScreen -> handleSetFullScreen(action) } } @@ -130,12 +132,11 @@ class MessageComposerViewModel @AssistedInject constructor( } private fun handleOnTextChanged(action: MessageComposerAction.OnTextChanged) { - setState { - // Makes sure currentComposerText is upToDate when accessing further setState - currentComposerText = action.text - this + val needsSendButtonVisibilityUpdate = currentComposerText.isEmpty() != action.text.isEmpty() + currentComposerText = SpannableString(action.text) + if (needsSendButtonVisibilityUpdate) { + updateIsSendButtonVisibility(true) } - updateIsSendButtonVisibility(true) } private fun subscribeToStateInternal() { @@ -163,6 +164,10 @@ class MessageComposerViewModel @AssistedInject constructor( } } + private fun handleSetFullScreen(action: MessageComposerAction.SetFullScreen) { + setState { copy(isFullScreen = action.isFullScreen) } + } + private fun observePowerLevelAndEncryption() { combine( PowerLevelsFlowFactory(room).createFlow(), diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt index 0df1dbebd8..7bb9509599 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt @@ -70,6 +70,7 @@ data class MessageComposerViewState( val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle, val voiceBroadcastState: VoiceBroadcastState? = null, val text: CharSequence? = null, + val isFullScreen: Boolean = false, ) : MavericksState { val isVoiceRecording = when (voiceRecordingUiState) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt index acb5a1b42a..939a59fcca 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt @@ -49,13 +49,6 @@ class PlainTextComposerLayout @JvmOverloads constructor( defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr), MessageComposerView { - interface Callback : ComposerEditText.Callback { - fun onCloseRelatedMessage() - fun onSendMessage(text: CharSequence) - fun onAddAttachment() - fun onExpandOrCompactChange() - } - private val views: ComposerLayoutBinding override var callback: Callback? = null @@ -83,6 +76,7 @@ class PlainTextComposerLayout @JvmOverloads constructor( } override val attachmentButton: ImageButton get() = views.attachmentButton + override val fullScreenButton: ImageButton? = null override val composerRelatedMessageActionIcon: ImageView get() = views.composerRelatedMessageActionIcon override val composerRelatedMessageAvatar: ImageView @@ -155,6 +149,10 @@ class PlainTextComposerLayout @JvmOverloads constructor( return views.composerEditText.setTextIfDifferent(text) } + override fun toggleFullScreen(newValue: Boolean) { + // Plain text composer has no full screen + } + private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { // val wasSendButtonInvisible = views.sendButton.isInvisible if (animate) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index 07b7d151ad..cac8f8bed4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -21,7 +21,6 @@ import android.text.Editable import android.text.TextWatcher import android.util.AttributeSet import android.view.LayoutInflater -import android.view.ViewGroup import android.widget.EditText import android.widget.ImageButton import android.widget.ImageView @@ -33,13 +32,8 @@ import androidx.constraintlayout.widget.ConstraintSet import androidx.core.text.toSpannable import androidx.core.view.isInvisible import androidx.core.view.isVisible -import androidx.transition.ChangeBounds -import androidx.transition.Fade -import androidx.transition.Transition -import androidx.transition.TransitionManager -import androidx.transition.TransitionSet import im.vector.app.R -import im.vector.app.core.animations.SimpleTransitionListener +import im.vector.app.core.extensions.animateLayoutChange import im.vector.app.core.extensions.setTextIfDifferent import im.vector.app.databinding.ComposerRichTextLayoutBinding import im.vector.app.databinding.ViewRichTextMenuButtonBinding @@ -56,12 +50,13 @@ class RichTextComposerLayout @JvmOverloads constructor( private val views: ComposerRichTextLayoutBinding - override var callback: PlainTextComposerLayout.Callback? = null + override var callback: Callback? = null private var currentConstraintSetId: Int = -1 - private val animationDuration = 100L + private var isFullScreen = false + override val text: Editable? get() = views.composerEditText.text override val formattedText: String? @@ -74,6 +69,8 @@ class RichTextComposerLayout @JvmOverloads constructor( get() = views.sendButton override val attachmentButton: ImageButton get() = views.attachmentButton + override val fullScreenButton: ImageButton? + get() = views.composerFullScreenButton override val composerRelatedMessageActionIcon: ImageView get() = views.composerRelatedMessageActionIcon override val composerRelatedMessageAvatar: ImageView @@ -124,6 +121,10 @@ class RichTextComposerLayout @JvmOverloads constructor( callback?.onAddAttachment() } + views.composerFullScreenButton.setOnClickListener { + callback?.onFullScreenModeChanged() + } + setupRichTextMenu() } @@ -205,34 +206,30 @@ class RichTextComposerLayout @JvmOverloads constructor( return views.composerEditText.setTextIfDifferent(text) } + override fun toggleFullScreen(newValue: Boolean) { + val constraintSetId = if (newValue) R.layout.composer_rich_text_layout_constraint_set_fullscreen else currentConstraintSetId + ConstraintSet().also { + it.clone(context, constraintSetId) + it.applyTo(this) + } + + updateTextFieldBorder(newValue) + } + private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { // val wasSendButtonInvisible = views.sendButton.isInvisible if (animate) { - configureAndBeginTransition(transitionComplete) + animateLayoutChange(animationDuration, transitionComplete) } ConstraintSet().also { it.clone(context, currentConstraintSetId) it.applyTo(this) } + // Might be updated by view state just after, but avoid blinks // views.sendButton.isInvisible = wasSendButtonInvisible } - private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) { - val transition = TransitionSet().apply { - ordering = TransitionSet.ORDERING_SEQUENTIAL - addTransition(ChangeBounds()) - addTransition(Fade(Fade.IN)) - duration = animationDuration - addListener(object : SimpleTransitionListener() { - override fun onTransitionEnd(transition: Transition) { - transitionComplete?.invoke() - } - }) - } - TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) - } - override fun setInvisible(isInvisible: Boolean) { this.isInvisible = isInvisible } diff --git a/vector/src/main/res/drawable/ic_composer_full_screen.xml b/vector/src/main/res/drawable/ic_composer_full_screen.xml new file mode 100644 index 0000000000..394dc52279 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_full_screen.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/layout/composer_rich_text_layout.xml b/vector/src/main/res/layout/composer_rich_text_layout.xml index 09e4b03887..9f49b8f9d6 100644 --- a/vector/src/main/res/layout/composer_rich_text_layout.xml +++ b/vector/src/main/res/layout/composer_rich_text_layout.xml @@ -3,7 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="wrap_content" + android:layout_height="match_parent" tools:constraintSet="@layout/composer_rich_text_layout_constraint_set_compact" tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout"> @@ -108,12 +108,24 @@ style="@style/Widget.Vector.EditText.RichTextComposer" android:layout_width="0dp" android:layout_height="wrap_content" + android:gravity="top" android:nextFocusLeft="@id/composerEditText" android:nextFocusUp="@id/composerEditText" tools:hint="@string/room_message_placeholder" tools:text="@tools:sample/lorem/random" tools:ignore="MissingConstraints" /> + + @@ -114,6 +114,7 @@ android:background="?android:attr/selectableItemBackground" android:contentDescription="@string/option_send_files" android:src="@drawable/ic_attachment" + app:layout_constraintVertical_bias="1" app:layout_constraintBottom_toBottomOf="@id/sendButton" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="@id/sendButton" @@ -142,14 +143,26 @@ android:hint="@string/room_message_placeholder" android:nextFocusLeft="@id/composerEditText" android:nextFocusUp="@id/composerEditText" - android:layout_marginHorizontal="12dp" + android:layout_marginStart="12dp" android:layout_marginVertical="10dp" app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder" - app:layout_constraintEnd_toEndOf="@id/composerEditTextOuterBorder" + app:layout_constraintEnd_toStartOf="@id/composerFullScreenButton" app:layout_constraintStart_toStartOf="@id/composerEditTextOuterBorder" app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder" tools:text="@tools:sample/lorem/random" /> + + @@ -173,6 +187,7 @@ app:layout_constraintStart_toEndOf="@id/attachmentButton" app:layout_constraintEnd_toStartOf="@id/sendButton" app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintVertical_bias="1" android:fillViewport="true"> @@ -156,14 +156,26 @@ android:hint="@string/room_message_placeholder" android:nextFocusLeft="@id/composerEditText" android:nextFocusUp="@id/composerEditText" - android:layout_marginHorizontal="12dp" + android:layout_marginStart="12dp" android:layout_marginVertical="10dp" app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder" - app:layout_constraintEnd_toEndOf="@id/composerEditTextOuterBorder" + app:layout_constraintEnd_toStartOf="@id/composerFullScreenButton" app:layout_constraintStart_toStartOf="@id/composerEditTextOuterBorder" app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder" tools:text="@tools:sample/lorem/random" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_composer.xml b/vector/src/main/res/layout/fragment_composer.xml index 8703af7471..41c052367a 100644 --- a/vector/src/main/res/layout/fragment_composer.xml +++ b/vector/src/main/res/layout/fragment_composer.xml @@ -4,7 +4,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:layout_height="match_parent"> + + + + @@ -165,6 +182,7 @@ android:layout_margin="16dp" android:contentDescription="@string/a11y_jump_to_bottom" android:src="@drawable/ic_expand_more" + android:visibility="gone" app:backgroundTint="#FFFFFF" app:badgeBackgroundColor="?colorPrimary" app:badgeTextColor="?colorOnPrimary" diff --git a/vector/src/main/res/layout/fragment_timeline_fullscreen.xml b/vector/src/main/res/layout/fragment_timeline_fullscreen.xml new file mode 100644 index 0000000000..373ca74f56 --- /dev/null +++ b/vector/src/main/res/layout/fragment_timeline_fullscreen.xml @@ -0,0 +1,258 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From c20f6fe3262761b918cbc5c686335884bea6e57f Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 16:07:38 +0200 Subject: [PATCH 26/41] GetOngoingVoiceBroadcastsUseCase: Remove debug logs --- .../usecase/GetOngoingVoiceBroadcastsUseCase.kt | 4 ---- 1 file changed, 4 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt index cb228ad8aa..0f5e413719 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt @@ -31,12 +31,8 @@ class GetOngoingVoiceBroadcastsUseCase @Inject constructor( ) { fun execute(roomId: String): List { - println("## GetOngoingVoiceBroadcastsUseCase") - println("## GetOngoingVoiceBroadcastsUseCase activeSessionHolder $activeSessionHolder") val session = activeSessionHolder.getSafeActiveSession() - println("## GetOngoingVoiceBroadcastsUseCase session $session") val room = session?.getRoom(roomId) ?: error("Unknown roomId: $roomId") - println("## GetOngoingVoiceBroadcastsUseCase room $room") Timber.d("## GetLastVoiceBroadcastUseCase: get last voice broadcast in $roomId") From cb5fc75c5d19751dba201b41f52cccde778893d0 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 16:08:03 +0200 Subject: [PATCH 27/41] GetOngoingVoiceBroadcastsUseCase: Return empty list if there is no session --- .../usecase/GetOngoingVoiceBroadcastsUseCase.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt index 0f5e413719..ec50618969 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt @@ -31,8 +31,11 @@ class GetOngoingVoiceBroadcastsUseCase @Inject constructor( ) { fun execute(roomId: String): List { - val session = activeSessionHolder.getSafeActiveSession() - val room = session?.getRoom(roomId) ?: error("Unknown roomId: $roomId") + val session = activeSessionHolder.getSafeActiveSession() ?: run { + Timber.d("## GetOngoingVoiceBroadcastsUseCase: no active session") + return emptyList() + } + val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") Timber.d("## GetLastVoiceBroadcastUseCase: get last voice broadcast in $roomId") From bdfc96ff666859b11376e91635c458dc242412ca Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 16:36:02 +0200 Subject: [PATCH 28/41] Fix merge conflicts --- .../detail/composer/MessageComposerFragment.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 3cb3eb1a4b..463a8fe440 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -91,8 +91,8 @@ import im.vector.app.features.poll.PollMode import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.share.SharedData import im.vector.app.features.voice.VoiceFailure -import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -207,6 +207,13 @@ class MessageComposerFragment : VectorBaseFragment(), A } } + messageComposerViewModel.stateFlow.map { it.isFullScreen } + .distinctUntilChanged() + .onEach { isFullScreen -> + composer.toggleFullScreen(isFullScreen) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + messageComposerViewModel.onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage) { mode, canSend -> if (!canSend.boolean()) { return@onEach @@ -220,13 +227,6 @@ class MessageComposerFragment : VectorBaseFragment(), A } } - messageComposerViewModel.stateFlow.map { it.isFullScreen } - .distinctUntilChanged() - .onEach { isFullScreen -> - composer.toggleFullScreen(isFullScreen) - } - .launchIn(viewLifecycleOwner.lifecycleScope) - if (savedInstanceState != null) { handleShareData() } From c776aae9d06052abe2eb0d799ac33cc77dc94e9f Mon Sep 17 00:00:00 2001 From: jonnyandrew Date: Wed, 26 Oct 2022 17:37:40 +0100 Subject: [PATCH 29/41] [Rich text editor] Add plain text mode and new attachment UI (#7459) * Add new attachments selection dialog * Add rounded corners to bottom sheet dialog. Note these are currently only visible in the collapsed state. - [Google issue](https://issuetracker.google.com/issues/144859239) - [Rejected PR](https://github.com/material-components/material-components-android/pull/437) - [Github issue](https://github.com/material-components/material-components-android/issues/1278) * Add changelog entry * Remove redundant call to superclass click listener * Refactor to use view visibility helper * Change redundant sealed class to interface * Remove unused string * Revert "Add rounded corners to bottom sheet dialog." This reverts commit 17c43c91888162d3c7675511ff910c46c3aa32fc. * Remove redundant view group * Remove redundant `this` * Update rich text editor to latest * Update rich text editor version * Allow toggling rich text in the new editor * Persist the text formatting setting * Add changelog entry --- changelog.d/7429.feature | 1 + changelog.d/7452.feature | 1 + dependencies.gradle | 2 +- .../src/main/res/values/strings.xml | 10 ++ .../app/core/di/MavericksViewModelModule.kt | 6 + .../core/ui/views/BottomSheetActionButton.kt | 4 + .../features/attachments/AttachmentType.kt | 37 +++++ .../AttachmentTypeSelectorBottomSheet.kt | 92 ++++++++++++ ...chmentTypeSelectorSharedActionViewModel.kt | 30 ++++ .../attachments/AttachmentTypeSelectorView.kt | 70 +++++---- .../AttachmentTypeSelectorViewModel.kt | 76 ++++++++++ .../features/attachments/AttachmentsHelper.kt | 2 +- .../composer/MessageComposerFragment.kt | 74 +++++---- .../detail/composer/RichTextComposerLayout.kt | 99 ++++++++---- .../features/settings/VectorPreferences.kt | 19 +++ .../main/res/drawable/ic_text_formatting.xml | 13 ++ .../drawable/ic_text_formatting_disabled.xml | 18 +++ .../bottom_sheet_attachment_type_selector.xml | 106 +++++++++++++ .../res/layout/composer_rich_text_layout.xml | 19 ++- ...ich_text_layout_constraint_set_compact.xml | 22 ++- ...ch_text_layout_constraint_set_expanded.xml | 22 ++- ..._text_layout_constraint_set_fullscreen.xml | 23 ++- .../AttachmentTypeSelectorViewModelTest.kt | 142 ++++++++++++++++++ .../app/test/fakes/FakeVectorFeatures.kt | 8 + .../app/test/fakes/FakeVectorPreferences.kt | 3 + 25 files changed, 797 insertions(+), 102 deletions(-) create mode 100644 changelog.d/7429.feature create mode 100644 changelog.d/7452.feature create mode 100644 vector/src/main/java/im/vector/app/features/attachments/AttachmentType.kt create mode 100644 vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt create mode 100644 vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorSharedActionViewModel.kt create mode 100644 vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModel.kt create mode 100644 vector/src/main/res/drawable/ic_text_formatting.xml create mode 100644 vector/src/main/res/drawable/ic_text_formatting_disabled.xml create mode 100644 vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml create mode 100644 vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt diff --git a/changelog.d/7429.feature b/changelog.d/7429.feature new file mode 100644 index 0000000000..9857452eca --- /dev/null +++ b/changelog.d/7429.feature @@ -0,0 +1 @@ +Add new UI for selecting an attachment diff --git a/changelog.d/7452.feature b/changelog.d/7452.feature new file mode 100644 index 0000000000..a811f87c84 --- /dev/null +++ b/changelog.d/7452.feature @@ -0,0 +1 @@ +[Rich text editor] Add plain text mode diff --git a/dependencies.gradle b/dependencies.gradle index f081e0a874..db6e92552a 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -101,7 +101,7 @@ ext.libs = [ ], element : [ 'opusencoder' : "io.element.android:opusencoder:1.1.0", - 'wysiwyg' : "io.element.android:wysiwyg:0.2.1" + 'wysiwyg' : "io.element.android:wysiwyg:0.4.0" ], squareup : [ 'moshi' : "com.squareup.moshi:moshi:$moshi", diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 450dcab1f7..9edd7d836a 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3205,6 +3205,16 @@ Share location Start a voice broadcast + Photo library + Stickers + Attachments + Voice broadcast + Polls + Location + Camera + Contact + Text formatting + Show less "%1$d more" diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 97590028d8..2242abb7aa 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -22,6 +22,7 @@ import dagger.hilt.InstallIn import dagger.multibindings.IntoMap import im.vector.app.features.analytics.accountdata.AnalyticsAccountDataViewModel import im.vector.app.features.analytics.ui.consent.AnalyticsConsentViewModel +import im.vector.app.features.attachments.AttachmentTypeSelectorViewModel import im.vector.app.features.auth.ReAuthViewModel import im.vector.app.features.call.VectorCallViewModel import im.vector.app.features.call.conference.JitsiCallViewModel @@ -677,4 +678,9 @@ interface MavericksViewModelModule { @IntoMap @MavericksViewModelKey(VectorSettingsLabsViewModel::class) fun vectorSettingsLabsViewModelFactory(factory: VectorSettingsLabsViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + + @Binds + @IntoMap + @MavericksViewModelKey(AttachmentTypeSelectorViewModel::class) + fun attachmentTypeSelectorViewModelFactory(factory: AttachmentTypeSelectorViewModel.Factory): MavericksAssistedViewModelFactory<*, *> } diff --git a/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt b/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt index a3e8b3780c..ca3e6a360a 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/BottomSheetActionButton.kt @@ -38,6 +38,10 @@ class BottomSheetActionButton @JvmOverloads constructor( ) : FrameLayout(context, attrs, defStyleAttr) { val views: ViewBottomSheetActionButtonBinding + override fun setOnClickListener(l: OnClickListener?) { + views.bottomSheetActionClickableZone.setOnClickListener(l) + } + var title: String? = null set(value) { field = value diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentType.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentType.kt new file mode 100644 index 0000000000..f4b97b9f9c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentType.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 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.attachments + +import im.vector.app.core.utils.PERMISSIONS_EMPTY +import im.vector.app.core.utils.PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING +import im.vector.app.core.utils.PERMISSIONS_FOR_PICKING_CONTACT +import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO +import im.vector.app.core.utils.PERMISSIONS_FOR_VOICE_BROADCAST + +/** + * The all possible types to pick with their required permissions. + */ +enum class AttachmentType(val permissions: List) { + CAMERA(PERMISSIONS_FOR_TAKING_PHOTO), + GALLERY(PERMISSIONS_EMPTY), + FILE(PERMISSIONS_EMPTY), + STICKER(PERMISSIONS_EMPTY), + CONTACT(PERMISSIONS_FOR_PICKING_CONTACT), + POLL(PERMISSIONS_EMPTY), + LOCATION(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING), + VOICE_BROADCAST(PERMISSIONS_FOR_VOICE_BROADCAST), +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt new file mode 100644 index 0000000000..f8d5d768ef --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2022 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.attachments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import com.airbnb.mvrx.parentFragmentViewModel +import com.airbnb.mvrx.withState +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment +import im.vector.app.databinding.BottomSheetAttachmentTypeSelectorBinding +import im.vector.app.features.home.room.detail.TimelineViewModel + +@AndroidEntryPoint +class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment() { + + private val viewModel: AttachmentTypeSelectorViewModel by parentFragmentViewModel() + private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() + private val sharedActionViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels( + ownerProducer = { requireParentFragment() } + ) + + override val showExpanded = true + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetAttachmentTypeSelectorBinding { + return BottomSheetAttachmentTypeSelectorBinding.inflate(inflater, container, false) + } + + override fun invalidate() = withState(viewModel, timelineViewModel) { viewState, timelineState -> + super.invalidate() + views.location.isVisible = viewState.isLocationVisible + views.voiceBroadcast.isVisible = viewState.isVoiceBroadcastVisible + views.poll.isVisible = !timelineState.isThreadTimeline() + views.textFormatting.isChecked = viewState.isTextFormattingEnabled + views.textFormatting.setCompoundDrawablesRelativeWithIntrinsicBounds( + if (viewState.isTextFormattingEnabled) { + R.drawable.ic_text_formatting + } else { + R.drawable.ic_text_formatting_disabled + }, 0, 0, 0 + ) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + views.gallery.debouncedClicks { onAttachmentSelected(AttachmentType.GALLERY) } + views.stickers.debouncedClicks { onAttachmentSelected(AttachmentType.STICKER) } + views.file.debouncedClicks { onAttachmentSelected(AttachmentType.FILE) } + views.voiceBroadcast.debouncedClicks { onAttachmentSelected(AttachmentType.VOICE_BROADCAST) } + views.poll.debouncedClicks { onAttachmentSelected(AttachmentType.POLL) } + views.location.debouncedClicks { onAttachmentSelected(AttachmentType.LOCATION) } + views.camera.debouncedClicks { onAttachmentSelected(AttachmentType.CAMERA) } + views.contact.debouncedClicks { onAttachmentSelected(AttachmentType.CONTACT) } + views.textFormatting.setOnCheckedChangeListener { _, isChecked -> onTextFormattingToggled(isChecked) } + } + + private fun onAttachmentSelected(attachmentType: AttachmentType) { + val action = AttachmentTypeSelectorSharedAction.SelectAttachmentTypeAction(attachmentType) + sharedActionViewModel.post(action) + dismiss() + } + + private fun onTextFormattingToggled(isEnabled: Boolean) = + viewModel.handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled)) + + companion object { + fun show(fragmentManager: FragmentManager) { + val bottomSheet = AttachmentTypeSelectorBottomSheet() + bottomSheet.show(fragmentManager, "AttachmentTypeSelectorBottomSheet") + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorSharedActionViewModel.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorSharedActionViewModel.kt new file mode 100644 index 0000000000..e02b10c54b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorSharedActionViewModel.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 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.attachments + +import im.vector.app.core.platform.VectorSharedAction +import im.vector.app.core.platform.VectorSharedActionViewModel +import javax.inject.Inject + +class AttachmentTypeSelectorSharedActionViewModel @Inject constructor() : + VectorSharedActionViewModel() + +sealed interface AttachmentTypeSelectorSharedAction : VectorSharedAction { + data class SelectAttachmentTypeAction( + val attachmentType: AttachmentType + ) : AttachmentTypeSelectorSharedAction +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt index 8536b765d4..55805a0728 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorView.kt @@ -30,17 +30,11 @@ import android.view.animation.TranslateAnimation import android.widget.ImageButton import android.widget.LinearLayout import android.widget.PopupWindow -import androidx.annotation.StringRes import androidx.appcompat.widget.TooltipCompat import androidx.core.view.doOnNextLayout import androidx.core.view.isVisible import im.vector.app.R import im.vector.app.core.epoxy.onClick -import im.vector.app.core.utils.PERMISSIONS_EMPTY -import im.vector.app.core.utils.PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING -import im.vector.app.core.utils.PERMISSIONS_FOR_PICKING_CONTACT -import im.vector.app.core.utils.PERMISSIONS_FOR_TAKING_PHOTO -import im.vector.app.core.utils.PERMISSIONS_FOR_VOICE_BROADCAST import im.vector.app.databinding.ViewAttachmentTypeSelectorBinding import im.vector.app.features.attachments.AttachmentTypeSelectorView.Callback import kotlin.math.max @@ -59,7 +53,7 @@ class AttachmentTypeSelectorView( ) : PopupWindow(context) { interface Callback { - fun onTypeSelected(type: Type) + fun onTypeSelected(type: AttachmentType) } private val views: ViewAttachmentTypeSelectorBinding @@ -69,14 +63,14 @@ class AttachmentTypeSelectorView( init { contentView = inflater.inflate(R.layout.view_attachment_type_selector, null, false) views = ViewAttachmentTypeSelectorBinding.bind(contentView) - views.attachmentGalleryButton.configure(Type.GALLERY) - views.attachmentCameraButton.configure(Type.CAMERA) - views.attachmentFileButton.configure(Type.FILE) - views.attachmentStickersButton.configure(Type.STICKER) - views.attachmentContactButton.configure(Type.CONTACT) - views.attachmentPollButton.configure(Type.POLL) - views.attachmentLocationButton.configure(Type.LOCATION) - views.attachmentVoiceBroadcast.configure(Type.VOICE_BROADCAST) + views.attachmentGalleryButton.configure(AttachmentType.GALLERY) + views.attachmentCameraButton.configure(AttachmentType.CAMERA) + views.attachmentFileButton.configure(AttachmentType.FILE) + views.attachmentStickersButton.configure(AttachmentType.STICKER) + views.attachmentContactButton.configure(AttachmentType.CONTACT) + views.attachmentPollButton.configure(AttachmentType.POLL) + views.attachmentLocationButton.configure(AttachmentType.LOCATION) + views.attachmentVoiceBroadcast.configure(AttachmentType.VOICE_BROADCAST) width = LinearLayout.LayoutParams.MATCH_PARENT height = LinearLayout.LayoutParams.WRAP_CONTENT animationStyle = 0 @@ -127,16 +121,16 @@ class AttachmentTypeSelectorView( } } - fun setAttachmentVisibility(type: Type, isVisible: Boolean) { + fun setAttachmentVisibility(type: AttachmentType, isVisible: Boolean) { when (type) { - Type.CAMERA -> views.attachmentCameraButton - Type.GALLERY -> views.attachmentGalleryButton - Type.FILE -> views.attachmentFileButton - Type.STICKER -> views.attachmentStickersButton - Type.CONTACT -> views.attachmentContactButton - Type.POLL -> views.attachmentPollButton - Type.LOCATION -> views.attachmentLocationButton - Type.VOICE_BROADCAST -> views.attachmentVoiceBroadcast + AttachmentType.CAMERA -> views.attachmentCameraButton + AttachmentType.GALLERY -> views.attachmentGalleryButton + AttachmentType.FILE -> views.attachmentFileButton + AttachmentType.STICKER -> views.attachmentStickersButton + AttachmentType.CONTACT -> views.attachmentContactButton + AttachmentType.POLL -> views.attachmentPollButton + AttachmentType.LOCATION -> views.attachmentLocationButton + AttachmentType.VOICE_BROADCAST -> views.attachmentVoiceBroadcast }.let { it.isVisible = isVisible } @@ -200,13 +194,13 @@ class AttachmentTypeSelectorView( return Pair(x, y) } - private fun ImageButton.configure(type: Type): ImageButton { + private fun ImageButton.configure(type: AttachmentType): ImageButton { this.setOnClickListener(TypeClickListener(type)) - TooltipCompat.setTooltipText(this, context.getString(type.tooltipRes)) + TooltipCompat.setTooltipText(this, context.getString(attachmentTooltipLabels.getValue(type))) return this } - private inner class TypeClickListener(private val type: Type) : View.OnClickListener { + private inner class TypeClickListener(private val type: AttachmentType) : View.OnClickListener { override fun onClick(v: View) { dismiss() @@ -217,14 +211,18 @@ class AttachmentTypeSelectorView( /** * The all possible types to pick with their required permissions and tooltip resource. */ - enum class Type(val permissions: List, @StringRes val tooltipRes: Int) { - CAMERA(PERMISSIONS_FOR_TAKING_PHOTO, R.string.tooltip_attachment_photo), - GALLERY(PERMISSIONS_EMPTY, R.string.tooltip_attachment_gallery), - FILE(PERMISSIONS_EMPTY, R.string.tooltip_attachment_file), - STICKER(PERMISSIONS_EMPTY, R.string.tooltip_attachment_sticker), - CONTACT(PERMISSIONS_FOR_PICKING_CONTACT, R.string.tooltip_attachment_contact), - POLL(PERMISSIONS_EMPTY, R.string.tooltip_attachment_poll), - LOCATION(PERMISSIONS_FOR_FOREGROUND_LOCATION_SHARING, R.string.tooltip_attachment_location), - VOICE_BROADCAST(PERMISSIONS_FOR_VOICE_BROADCAST, R.string.tooltip_attachment_voice_broadcast), + private companion object { + private val attachmentTooltipLabels: Map = AttachmentType.values().associateWith { + when (it) { + AttachmentType.CAMERA -> R.string.tooltip_attachment_photo + AttachmentType.GALLERY -> R.string.tooltip_attachment_gallery + AttachmentType.FILE -> R.string.tooltip_attachment_file + AttachmentType.STICKER -> R.string.tooltip_attachment_sticker + AttachmentType.CONTACT -> R.string.tooltip_attachment_contact + AttachmentType.POLL -> R.string.tooltip_attachment_poll + AttachmentType.LOCATION -> R.string.tooltip_attachment_location + AttachmentType.VOICE_BROADCAST -> R.string.tooltip_attachment_voice_broadcast + } + } } } diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModel.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModel.kt new file mode 100644 index 0000000000..cb74661eba --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModel.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 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.attachments + +import com.airbnb.mvrx.MavericksState +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.core.platform.VectorViewModelAction +import im.vector.app.features.VectorFeatures +import im.vector.app.features.settings.VectorPreferences + +class AttachmentTypeSelectorViewModel @AssistedInject constructor( + @Assisted initialState: AttachmentTypeSelectorViewState, + private val vectorFeatures: VectorFeatures, + private val vectorPreferences: VectorPreferences, +) : VectorViewModel(initialState) { + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: AttachmentTypeSelectorViewState): AttachmentTypeSelectorViewModel + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + override fun handle(action: AttachmentTypeSelectorAction) = when (action) { + is AttachmentTypeSelectorAction.ToggleTextFormatting -> setTextFormattingEnabled(action.isEnabled) + } + + init { + setState { + copy( + isLocationVisible = vectorFeatures.isLocationSharingEnabled(), + isVoiceBroadcastVisible = vectorFeatures.isVoiceBroadcastEnabled(), + isTextFormattingEnabled = vectorPreferences.isTextFormattingEnabled(), + ) + } + } + + private fun setTextFormattingEnabled(isEnabled: Boolean) { + vectorPreferences.setTextFormattingEnabled(isEnabled) + setState { + copy( + isTextFormattingEnabled = isEnabled + ) + } + } +} + +data class AttachmentTypeSelectorViewState( + val isLocationVisible: Boolean = false, + val isVoiceBroadcastVisible: Boolean = false, + val isTextFormattingEnabled: Boolean = false, +) : MavericksState + +sealed interface AttachmentTypeSelectorAction : VectorViewModelAction { + data class ToggleTextFormatting(val isEnabled: Boolean) : AttachmentTypeSelectorAction +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt index 1a8e10d102..9692777e15 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt @@ -54,7 +54,7 @@ class AttachmentsHelper( private var captureUri: Uri? = null // The pending type is set if we have to handle permission request. It must be restored if the activity gets killed. - var pendingType: AttachmentTypeSelectorView.Type? = null + var pendingType: AttachmentType? = null // Restorable diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index 463a8fe440..5666c28605 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -40,8 +40,10 @@ import androidx.core.text.buildSpannedString import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.parentFragmentViewModel import com.airbnb.mvrx.withState import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -63,7 +65,12 @@ import im.vector.app.core.utils.onPermissionDeniedDialog import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.databinding.FragmentComposerBinding import im.vector.app.features.VectorFeatures +import im.vector.app.features.attachments.AttachmentType +import im.vector.app.features.attachments.AttachmentTypeSelectorBottomSheet +import im.vector.app.features.attachments.AttachmentTypeSelectorSharedAction +import im.vector.app.features.attachments.AttachmentTypeSelectorSharedActionViewModel import im.vector.app.features.attachments.AttachmentTypeSelectorView +import im.vector.app.features.attachments.AttachmentTypeSelectorViewModel import im.vector.app.features.attachments.AttachmentsHelper import im.vector.app.features.attachments.ContactAttachment import im.vector.app.features.attachments.ShareIntentHandler @@ -91,8 +98,9 @@ import im.vector.app.features.poll.PollMode import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.share.SharedData import im.vector.app.features.voice.VoiceFailure -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -162,6 +170,8 @@ class MessageComposerFragment : VectorBaseFragment(), A private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel() private lateinit var sharedActionViewModel: MessageSharedActionViewModel + private val attachmentViewModel: AttachmentTypeSelectorViewModel by fragmentViewModel() + private val attachmentActionsViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels() private val composer: MessageComposerView get() { return if (vectorPreferences.isRichTextEditorEnabled()) { @@ -227,6 +237,11 @@ class MessageComposerFragment : VectorBaseFragment(), A } } + attachmentActionsViewModel.stream() + .filterIsInstance() + .onEach { onTypeSelected(it.attachmentType) } + .launchIn(lifecycleScope) + if (savedInstanceState != null) { handleShareData() } @@ -260,11 +275,14 @@ class MessageComposerFragment : VectorBaseFragment(), A messageComposerViewModel.endAllVoiceActions() } - override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> + override fun invalidate() = withState( + timelineViewModel, messageComposerViewModel, attachmentViewModel + ) { mainState, messageComposerState, attachmentState -> if (mainState.tombstoneEvent != null) return@withState composer.setInvisible(!messageComposerState.isComposerVisible) composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible + (composer as? RichTextComposerLayout)?.isTextFormattingEnabled = attachmentState.isTextFormattingEnabled } private fun setupComposer() { @@ -307,21 +325,25 @@ class MessageComposerFragment : VectorBaseFragment(), A } composer.callback = object : Callback { override fun onAddAttachment() { - if (!::attachmentTypeSelector.isInitialized) { - attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@MessageComposerFragment) - attachmentTypeSelector.setAttachmentVisibility( - AttachmentTypeSelectorView.Type.LOCATION, - vectorFeatures.isLocationSharingEnabled(), - ) - attachmentTypeSelector.setAttachmentVisibility( - AttachmentTypeSelectorView.Type.POLL, !isThreadTimeLine() - ) - attachmentTypeSelector.setAttachmentVisibility( - AttachmentTypeSelectorView.Type.VOICE_BROADCAST, - vectorPreferences.isVoiceBroadcastEnabled(), // TODO check user permission - ) + if (vectorPreferences.isRichTextEditorEnabled()) { + AttachmentTypeSelectorBottomSheet.show(childFragmentManager) + } else { + if (!::attachmentTypeSelector.isInitialized) { + attachmentTypeSelector = AttachmentTypeSelectorView(vectorBaseActivity, vectorBaseActivity.layoutInflater, this@MessageComposerFragment) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentType.LOCATION, + vectorFeatures.isLocationSharingEnabled(), + ) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentType.POLL, !isThreadTimeLine() + ) + attachmentTypeSelector.setAttachmentVisibility( + AttachmentType.VOICE_BROADCAST, + vectorPreferences.isVoiceBroadcastEnabled(), // TODO check user permission + ) + } + attachmentTypeSelector.show(composer.attachmentButton) } - attachmentTypeSelector.show(composer.attachmentButton) } override fun onExpandOrCompactChange() { @@ -678,20 +700,20 @@ class MessageComposerFragment : VectorBaseFragment(), A } } - private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) { + private fun launchAttachmentProcess(type: AttachmentType) { when (type) { - AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera( + AttachmentType.CAMERA -> attachmentsHelper.openCamera( activity = requireActivity(), vectorPreferences = vectorPreferences, cameraActivityResultLauncher = attachmentCameraActivityResultLauncher, cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher ) - AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) - AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) - AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) - AttachmentTypeSelectorView.Type.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment) - AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), roomId, null, PollMode.CREATE) - AttachmentTypeSelectorView.Type.LOCATION -> { + AttachmentType.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) + AttachmentType.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) + AttachmentType.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) + AttachmentType.STICKER -> timelineViewModel.handle(RoomDetailAction.SelectStickerAttachment) + AttachmentType.POLL -> navigator.openCreatePoll(requireContext(), roomId, null, PollMode.CREATE) + AttachmentType.LOCATION -> { navigator .openLocationSharing( context = requireContext(), @@ -701,11 +723,11 @@ class MessageComposerFragment : VectorBaseFragment(), A locationOwnerId = session.myUserId ) } - AttachmentTypeSelectorView.Type.VOICE_BROADCAST -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Start) + AttachmentType.VOICE_BROADCAST -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Start) } } - override fun onTypeSelected(type: AttachmentTypeSelectorView.Type) { + override fun onTypeSelected(type: AttachmentType) { if (checkPermissions(type.permissions, requireActivity(), typeSelectedActivityResultLauncher)) { launchAttachmentProcess(type) } else { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index cac8f8bed4..2c09f351bb 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -38,7 +38,7 @@ import im.vector.app.core.extensions.setTextIfDifferent import im.vector.app.databinding.ComposerRichTextLayoutBinding import im.vector.app.databinding.ViewRichTextMenuButtonBinding import io.element.android.wysiwyg.EditorEditText -import io.element.android.wysiwyg.InlineFormat +import io.element.android.wysiwyg.inputhandlers.models.InlineFormat import uniffi.wysiwyg_composer.ComposerAction import uniffi.wysiwyg_composer.MenuState @@ -57,12 +57,24 @@ class RichTextComposerLayout @JvmOverloads constructor( private var isFullScreen = false + var isTextFormattingEnabled = true + set(value) { + if (field == value) return + syncEditTexts() + field = value + updateEditTextVisibility() + } + override val text: Editable? - get() = views.composerEditText.text + get() = editText.text override val formattedText: String? - get() = views.composerEditText.getHtmlOutput() + get() = (editText as? EditorEditText)?.getHtmlOutput() override val editText: EditText - get() = views.composerEditText + get() = if (isTextFormattingEnabled) { + views.richTextComposerEditText + } else { + views.plainTextComposerEditText + } override val emojiButton: ImageButton? get() = null override val sendButton: ImageButton @@ -91,21 +103,12 @@ class RichTextComposerLayout @JvmOverloads constructor( collapse(false) - views.composerEditText.addTextChangedListener(object : TextWatcher { - private var previousTextWasExpanded = false - - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - override fun afterTextChanged(s: Editable) { - callback?.onTextChanged(s) - - val isExpanded = s.lines().count() > 1 - if (previousTextWasExpanded != isExpanded) { - updateTextFieldBorder(isExpanded) - } - previousTextWasExpanded = isExpanded - } - }) + views.richTextComposerEditText.addTextChangedListener( + TextChangeListener({ callback?.onTextChanged(it) }, ::updateTextFieldBorder) + ) + views.plainTextComposerEditText.addTextChangedListener( + TextChangeListener({ callback?.onTextChanged(it) }, ::updateTextFieldBorder) + ) views.composerRelatedMessageCloseButton.setOnClickListener { collapse() @@ -130,19 +133,23 @@ class RichTextComposerLayout @JvmOverloads constructor( private fun setupRichTextMenu() { addRichTextMenuItem(R.drawable.ic_composer_bold, R.string.rich_text_editor_format_bold, ComposerAction.Bold) { - views.composerEditText.toggleInlineFormat(InlineFormat.Bold) + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Bold) } addRichTextMenuItem(R.drawable.ic_composer_italic, R.string.rich_text_editor_format_italic, ComposerAction.Italic) { - views.composerEditText.toggleInlineFormat(InlineFormat.Italic) + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Italic) } addRichTextMenuItem(R.drawable.ic_composer_underlined, R.string.rich_text_editor_format_underline, ComposerAction.Underline) { - views.composerEditText.toggleInlineFormat(InlineFormat.Underline) + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Underline) } addRichTextMenuItem(R.drawable.ic_composer_strikethrough, R.string.rich_text_editor_format_strikethrough, ComposerAction.StrikeThrough) { - views.composerEditText.toggleInlineFormat(InlineFormat.StrikeThrough) + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.StrikeThrough) } + } - views.composerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state -> + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + views.richTextComposerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state -> if (state is MenuState.Update) { updateMenuStateFor(ComposerAction.Bold, state) updateMenuStateFor(ComposerAction.Italic, state) @@ -150,8 +157,26 @@ class RichTextComposerLayout @JvmOverloads constructor( updateMenuStateFor(ComposerAction.StrikeThrough, state) } } + + updateEditTextVisibility() } + private fun updateEditTextVisibility() { + views.richTextComposerEditText.isVisible = isTextFormattingEnabled + views.richTextMenu.isVisible = isTextFormattingEnabled + views.plainTextComposerEditText.isVisible = !isTextFormattingEnabled + } + + /** + * Updates the non-active input with the contents of the active input. + */ + private fun syncEditTexts() = + if (isTextFormattingEnabled) { + views.plainTextComposerEditText.setText(views.richTextComposerEditText.getPlainText()) + } else { + views.richTextComposerEditText.setText(views.plainTextComposerEditText.text.toString()) + } + private fun addRichTextMenuItem(@DrawableRes iconId: Int, @StringRes description: Int, action: ComposerAction, onClick: () -> Unit) { val inflater = LayoutInflater.from(context) val button = ViewRichTextMenuButtonBinding.inflate(inflater, views.richTextMenu, true) @@ -181,7 +206,7 @@ class RichTextComposerLayout @JvmOverloads constructor( } override fun replaceFormattedContent(text: CharSequence) { - views.composerEditText.setHtml(text.toString()) + views.richTextComposerEditText.setHtml(text.toString()) } override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { @@ -191,6 +216,7 @@ class RichTextComposerLayout @JvmOverloads constructor( } currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_compact applyNewConstraintSet(animate, transitionComplete) + updateEditTextVisibility() } override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { @@ -200,10 +226,11 @@ class RichTextComposerLayout @JvmOverloads constructor( } currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_expanded applyNewConstraintSet(animate, transitionComplete) + updateEditTextVisibility() } override fun setTextIfDifferent(text: CharSequence?): Boolean { - return views.composerEditText.setTextIfDifferent(text) + return editText.setTextIfDifferent(text) } override fun toggleFullScreen(newValue: Boolean) { @@ -214,6 +241,7 @@ class RichTextComposerLayout @JvmOverloads constructor( } updateTextFieldBorder(newValue) + updateEditTextVisibility() } private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { @@ -233,4 +261,23 @@ class RichTextComposerLayout @JvmOverloads constructor( override fun setInvisible(isInvisible: Boolean) { this.isInvisible = isInvisible } + + private class TextChangeListener( + private val onTextChanged: (s: Editable) -> Unit, + private val onExpandedChanged: (isExpanded: Boolean) -> Unit, + ) : TextWatcher { + private var previousTextWasExpanded = false + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable) { + onTextChanged.invoke(s) + + val isExpanded = s.lines().count() > 1 + if (previousTextWasExpanded != isExpanded) { + onExpandedChanged(isExpanded) + } + previousTextWasExpanded = isExpanded + } + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index 2dc8b12160..9f40a7cede 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -109,6 +109,7 @@ class VectorPreferences @Inject constructor( const val SETTINGS_SHOW_URL_PREVIEW_KEY = "SETTINGS_SHOW_URL_PREVIEW_KEY" private const val SETTINGS_SEND_TYPING_NOTIF_KEY = "SETTINGS_SEND_TYPING_NOTIF_KEY" private const val SETTINGS_ENABLE_MARKDOWN_KEY = "SETTINGS_ENABLE_MARKDOWN_KEY" + private const val SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY = "SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY" private const val SETTINGS_ALWAYS_SHOW_TIMESTAMPS_KEY = "SETTINGS_ALWAYS_SHOW_TIMESTAMPS_KEY" private const val SETTINGS_12_24_TIMESTAMPS_KEY = "SETTINGS_12_24_TIMESTAMPS_KEY" private const val SETTINGS_SHOW_READ_RECEIPTS_KEY = "SETTINGS_SHOW_READ_RECEIPTS_KEY" @@ -759,6 +760,24 @@ class VectorPreferences @Inject constructor( } } + /** + * Tells if text formatting is enabled within the rich text editor. + * + * @return true if the text formatting is enabled + */ + fun isTextFormattingEnabled(): Boolean = + defaultPrefs.getBoolean(SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY, true) + + /** + * Update whether text formatting is enabled within the rich text editor. + * + * @param isEnabled true to enable the text formatting + */ + fun setTextFormattingEnabled(isEnabled: Boolean) = + defaultPrefs.edit { + putBoolean(SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY, isEnabled) + } + /** * Tells if a confirmation dialog should be displayed before staring a call. */ diff --git a/vector/src/main/res/drawable/ic_text_formatting.xml b/vector/src/main/res/drawable/ic_text_formatting.xml new file mode 100644 index 0000000000..375c459692 --- /dev/null +++ b/vector/src/main/res/drawable/ic_text_formatting.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/vector/src/main/res/drawable/ic_text_formatting_disabled.xml b/vector/src/main/res/drawable/ic_text_formatting_disabled.xml new file mode 100644 index 0000000000..bb34211c7a --- /dev/null +++ b/vector/src/main/res/drawable/ic_text_formatting_disabled.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml b/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml new file mode 100644 index 0000000000..7a22ab57f8 --- /dev/null +++ b/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/composer_rich_text_layout.xml b/vector/src/main/res/layout/composer_rich_text_layout.xml index 9f49b8f9d6..c5afe1eb44 100644 --- a/vector/src/main/res/layout/composer_rich_text_layout.xml +++ b/vector/src/main/res/layout/composer_rich_text_layout.xml @@ -104,13 +104,26 @@ android:background="@drawable/bg_composer_rich_edit_text_single_line" /> + + + diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml index 7aaa9f6a07..1a3023a805 100644 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml +++ b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml @@ -136,13 +136,29 @@ app:layout_constraintEnd_toEndOf="parent" /> + + + + + + () { fun givenCombinedLoginDisabled() { every { isOnboardingCombinedLoginEnabled() } returns false } + + fun givenLocationSharing(isEnabled: Boolean) { + every { isLocationSharingEnabled() } returns isEnabled + } + + fun givenVoiceBroadcast(isEnabled: Boolean) { + every { isVoiceBroadcastEnabled() } returns isEnabled + } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt index 8b0630c24f..cd4f70bf63 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt @@ -40,4 +40,7 @@ class FakeVectorPreferences { fun givenIsClientInfoRecordingEnabled(isEnabled: Boolean) { every { instance.isClientInfoRecordingEnabled() } returns isEnabled } + + fun givenTextFormatting(isEnabled: Boolean) = + every { instance.isTextFormattingEnabled() } returns isEnabled } From 40ea00f865905f087fcd1ef7716baa827feec2c6 Mon Sep 17 00:00:00 2001 From: Benoit Marty Date: Thu, 27 Oct 2022 15:54:24 +0200 Subject: [PATCH 30/41] Empty commit to trigger CI From 174ba4f4cc68890e333af86eead16e46989123b3 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Wed, 26 Oct 2022 16:03:06 +0200 Subject: [PATCH 31/41] VoiceBroadcastPlayer - Create player interface and move implementation to dedicated class --- .../java/im/vector/app/core/di/VoiceModule.kt | 31 +++++--- .../features/home/HomeActivityViewModel.kt | 2 +- .../factory/VoiceBroadcastItemFactory.kt | 4 +- .../item/AbsMessageVoiceBroadcastItem.kt | 4 +- .../MessageVoiceBroadcastListeningItem.kt | 2 +- .../MessageVoiceBroadcastRecordingItem.kt | 2 +- .../voicebroadcast/VoiceBroadcastHelper.kt | 9 ++- .../listening/VoiceBroadcastPlayer.kt | 75 +++++++++++++++++++ .../VoiceBroadcastPlayerImpl.kt} | 62 +++++++-------- .../{ => recording}/VoiceBroadcastRecorder.kt | 2 +- .../VoiceBroadcastRecorderQ.kt | 2 +- .../usecase/PauseVoiceBroadcastUseCase.kt | 4 +- .../usecase/ResumeVoiceBroadcastUseCase.kt | 4 +- .../usecase/StartVoiceBroadcastUseCase.kt | 5 +- .../StopOngoingVoiceBroadcastUseCase.kt | 3 +- .../usecase/StopVoiceBroadcastUseCase.kt | 4 +- .../usecase/PauseVoiceBroadcastUseCaseTest.kt | 3 +- .../ResumeVoiceBroadcastUseCaseTest.kt | 3 +- .../usecase/StartVoiceBroadcastUseCaseTest.kt | 3 +- .../usecase/StopVoiceBroadcastUseCaseTest.kt | 3 +- 20 files changed, 156 insertions(+), 71 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt rename vector/src/main/java/im/vector/app/features/voicebroadcast/{VoiceBroadcastPlayer.kt => listening/VoiceBroadcastPlayerImpl.kt} (89%) rename vector/src/main/java/im/vector/app/features/voicebroadcast/{ => recording}/VoiceBroadcastRecorder.kt (95%) rename vector/src/main/java/im/vector/app/features/voicebroadcast/{ => recording}/VoiceBroadcastRecorderQ.kt (98%) rename vector/src/main/java/im/vector/app/features/voicebroadcast/{ => recording}/usecase/PauseVoiceBroadcastUseCase.kt (95%) rename vector/src/main/java/im/vector/app/features/voicebroadcast/{ => recording}/usecase/ResumeVoiceBroadcastUseCase.kt (95%) rename vector/src/main/java/im/vector/app/features/voicebroadcast/{ => recording}/usecase/StartVoiceBroadcastUseCase.kt (95%) rename vector/src/main/java/im/vector/app/features/voicebroadcast/{ => recording}/usecase/StopOngoingVoiceBroadcastUseCase.kt (95%) rename vector/src/main/java/im/vector/app/features/voicebroadcast/{ => recording}/usecase/StopVoiceBroadcastUseCase.kt (95%) diff --git a/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt b/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt index 54d556ea91..30a8565771 100644 --- a/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt @@ -18,24 +18,33 @@ package im.vector.app.core.di import android.content.Context import android.os.Build +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorderQ +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayerImpl +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorderQ import javax.inject.Singleton -@Module @InstallIn(SingletonComponent::class) -object VoiceModule { - @Provides - @Singleton - fun providesVoiceBroadcastRecorder(context: Context): VoiceBroadcastRecorder? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - VoiceBroadcastRecorderQ(context) - } else { - null +@Module +abstract class VoiceModule { + + companion object { + @Provides + @Singleton + fun providesVoiceBroadcastRecorder(context: Context): VoiceBroadcastRecorder? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + VoiceBroadcastRecorderQ(context) + } else { + null + } } } + + @Binds + abstract fun bindVoiceBroadcastPlayer(player: VoiceBroadcastPlayerImpl): VoiceBroadcastPlayer } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt index c3abdde022..49f2079625 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt @@ -42,7 +42,7 @@ import im.vector.app.features.raw.wellknown.isSecureBackupRequired import im.vector.app.features.raw.wellknown.withElementWellKnown import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorPreferences -import im.vector.app.features.voicebroadcast.usecase.StopOngoingVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.recording.usecase.StopOngoingVoiceBroadcastUseCase import im.vector.lib.core.utils.compat.getParcelableExtraCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt index 7a7cb73471..56498fa8d3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt @@ -26,11 +26,11 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadca import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastListeningItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastRecordingItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastRecordingItem_ -import im.vector.app.features.voicebroadcast.VoiceBroadcastPlayer -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.getUserOrDefault diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt index 45f10b68d0..ba9d582ea4 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt @@ -25,9 +25,9 @@ import im.vector.app.R import im.vector.app.core.extensions.tintBackground import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider -import im.vector.app.features.voicebroadcast.VoiceBroadcastPlayer -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.util.MatrixItem abstract class AbsMessageVoiceBroadcastItem : AbsMessageItem() { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt index a3e7cc55d5..8df7a9d1a6 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt @@ -23,7 +23,7 @@ import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.onClick import im.vector.app.features.home.room.detail.RoomDetailAction -import im.vector.app.features.voicebroadcast.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView @EpoxyModelClass diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt index e3e86f38e3..17aa1543c0 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt @@ -22,8 +22,8 @@ import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.onClick import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView @EpoxyModelClass diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt index 58e7de7f32..dfc8e35422 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt @@ -16,10 +16,11 @@ package im.vector.app.features.voicebroadcast -import im.vector.app.features.voicebroadcast.usecase.PauseVoiceBroadcastUseCase -import im.vector.app.features.voicebroadcast.usecase.ResumeVoiceBroadcastUseCase -import im.vector.app.features.voicebroadcast.usecase.StartVoiceBroadcastUseCase -import im.vector.app.features.voicebroadcast.usecase.StopVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.recording.usecase.PauseVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.recording.usecase.ResumeVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.recording.usecase.StartVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.recording.usecase.StopVoiceBroadcastUseCase import javax.inject.Inject /** diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt new file mode 100644 index 0000000000..e2870c4011 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2022 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.voicebroadcast.listening + +interface VoiceBroadcastPlayer { + + /** + * The current playing voice broadcast identifier, if any. + */ + val currentVoiceBroadcastId: String? + + /** + * The current playing [State], [State.IDLE] by default. + */ + val playingState: State + + /** + * Start playback of the given voice broadcast. + */ + fun playOrResume(roomId: String, voiceBroadcastId: String) + + /** + * Pause playback of the current voice broadcast, if any. + */ + fun pause() + + /** + * Stop playback of the current voice broadcast, if any, and reset the player state. + */ + fun stop() + + /** + * Add a [Listener] to the given voice broadcast id. + */ + fun addListener(voiceBroadcastId: String, listener: Listener) + + /** + * Remove a [Listener] from the given voice broadcast id. + */ + fun removeListener(voiceBroadcastId: String, listener: Listener) + + /** + * Player states. + */ + enum class State { + PLAYING, + PAUSED, + BUFFERING, + IDLE + } + + /** + * Listener related to [VoiceBroadcastPlayer]. + */ + fun interface Listener { + /** + * Notify about [VoiceBroadcastPlayer.playingState] changes. + */ + fun onStateChanged(state: State) + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt similarity index 89% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt index 5a04904f69..168b921c2e 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast +package im.vector.app.features.voicebroadcast.listening import android.media.AudioAttributes import android.media.MediaPlayer @@ -22,8 +22,14 @@ import androidx.annotation.MainThread import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.voice.VoiceFailure +import im.vector.app.features.voicebroadcast.getVoiceBroadcastChunk +import im.vector.app.features.voicebroadcast.getVoiceBroadcastEventId +import im.vector.app.features.voicebroadcast.isVoiceBroadcast +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.Listener +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.State import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.sequence import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -43,14 +49,13 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import timber.log.Timber import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject -import javax.inject.Singleton @Singleton -class VoiceBroadcastPlayer @Inject constructor( +class VoiceBroadcastPlayerImpl @Inject constructor( private val sessionHolder: ActiveSessionHolder, private val playbackTracker: AudioMessagePlaybackTracker, private val getVoiceBroadcastUseCase: GetVoiceBroadcastUseCase, -) { +) : VoiceBroadcastPlayer { private val session get() = sessionHolder.getActiveSession() @@ -75,9 +80,9 @@ class VoiceBroadcastPlayer @Inject constructor( private var currentSequence: Int? = null private var playlist = emptyList() - var currentVoiceBroadcastId: String? = null + override var currentVoiceBroadcastId: String? = null - private var state: State = State.IDLE + override var playingState = State.IDLE @MainThread set(value) { Timber.w("## VoiceBroadcastPlayer state: $field -> $value") @@ -94,22 +99,22 @@ class VoiceBroadcastPlayer @Inject constructor( */ private val listeners: MutableMap> = mutableMapOf() - fun playOrResume(roomId: String, eventId: String) { - val hasChanged = currentVoiceBroadcastId != eventId + override fun playOrResume(roomId: String, voiceBroadcastId: String) { + val hasChanged = currentVoiceBroadcastId != voiceBroadcastId when { - hasChanged -> startPlayback(roomId, eventId) - state == State.PAUSED -> resumePlayback() + hasChanged -> startPlayback(roomId, voiceBroadcastId) + playingState == State.PAUSED -> resumePlayback() else -> Unit } } - fun pause() { + override fun pause() { currentMediaPlayer?.pause() currentVoiceBroadcastId?.let { playbackTracker.pausePlayback(it) } - state = State.PAUSED + playingState = State.PAUSED } - fun stop() { + override fun stop() { // Stop playback currentMediaPlayer?.stop() currentVoiceBroadcastId?.let { playbackTracker.stopPlayback(it) } @@ -131,7 +136,7 @@ class VoiceBroadcastPlayer @Inject constructor( timelineListener = null // Update state - state = State.IDLE + playingState = State.IDLE // Clear playlist playlist = emptyList() @@ -143,29 +148,29 @@ class VoiceBroadcastPlayer @Inject constructor( /** * Add a [Listener] to the given voice broadcast id. */ - fun addListener(voiceBroadcastId: String, listener: Listener) { + override fun addListener(voiceBroadcastId: String, listener: Listener) { listeners[voiceBroadcastId]?.add(listener) ?: run { listeners[voiceBroadcastId] = CopyOnWriteArrayList().apply { add(listener) } } - if (voiceBroadcastId == currentVoiceBroadcastId) listener.onStateChanged(state) else listener.onStateChanged(State.IDLE) + if (voiceBroadcastId == currentVoiceBroadcastId) listener.onStateChanged(playingState) else listener.onStateChanged(State.IDLE) } /** * Remove a [Listener] from the given voice broadcast id. */ - fun removeListener(voiceBroadcastId: String, listener: Listener) { + override fun removeListener(voiceBroadcastId: String, listener: Listener) { listeners[voiceBroadcastId]?.remove(listener) } private fun startPlayback(roomId: String, eventId: String) { val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") // Stop listening previous voice broadcast if any - if (state != State.IDLE) stop() + if (playingState != State.IDLE) stop() currentRoomId = roomId currentVoiceBroadcastId = eventId - state = State.BUFFERING + playingState = State.BUFFERING val voiceBroadcastState = getVoiceBroadcastUseCase.execute(roomId, eventId)?.content?.voiceBroadcastState if (voiceBroadcastState == VoiceBroadcastState.STOPPED) { @@ -187,7 +192,7 @@ class VoiceBroadcastPlayer @Inject constructor( currentMediaPlayer?.start() currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } currentSequence = sequence - withContext(Dispatchers.Main) { state = State.PLAYING } + withContext(Dispatchers.Main) { playingState = State.PLAYING } nextMediaPlayer = prepareNextMediaPlayer() } catch (failure: Throwable) { Timber.e(failure, "Unable to start playback") @@ -219,7 +224,7 @@ class VoiceBroadcastPlayer @Inject constructor( private fun resumePlayback() { currentMediaPlayer?.start() currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } - state = State.PLAYING + playingState = State.PLAYING } private fun updatePlaylist(playlist: List) { @@ -285,7 +290,7 @@ class VoiceBroadcastPlayer @Inject constructor( if (newChunks.isEmpty()) return updatePlaylist(playlist + newChunks) - when (state) { + when (playingState) { State.PLAYING -> { if (nextMediaPlayer == null) { coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } @@ -330,7 +335,7 @@ class VoiceBroadcastPlayer @Inject constructor( // We'll not receive new chunks anymore so we can stop the live listening stop() } else { - state = State.BUFFERING + playingState = State.BUFFERING } } @@ -339,15 +344,4 @@ class VoiceBroadcastPlayer @Inject constructor( return true } } - - enum class State { - PLAYING, - PAUSED, - BUFFERING, - IDLE - } - - fun interface Listener { - fun onStateChanged(state: State) - } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt similarity index 95% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt index 8b69051823..8bc33ed769 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast +package im.vector.app.features.voicebroadcast.recording import androidx.annotation.IntRange import im.vector.app.features.voice.VoiceRecorder diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt similarity index 98% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt index 5285dc5e3b..519f1f24aa 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast +package im.vector.app.features.voicebroadcast.recording import android.content.Context import android.media.MediaRecorder diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt similarity index 95% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt index 1430dd8c86..58e1f26f44 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.toContent diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt similarity index 95% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt index 2f03d4194c..524b64e095 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.toContent diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt similarity index 95% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt index 2b7ca7b9f1..a1a519a656 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt @@ -14,17 +14,18 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import android.content.Context import androidx.core.content.FileProvider import im.vector.app.core.resources.BuildMeta import im.vector.app.features.attachments.toContentAttachmentData import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase import im.vector.lib.multipicker.utils.toMultiPickerAudioType import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.RelationType diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopOngoingVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt similarity index 95% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopOngoingVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt index ab4d16ab60..791409b869 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopOngoingVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt @@ -14,11 +14,12 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.room.model.Membership diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt similarity index 95% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt index bc6a3e7be6..da13100609 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.toContent diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCaseTest.kt index 5c42b26c54..a1ec91aab8 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCaseTest.kt @@ -17,9 +17,10 @@ package im.vector.app.features.voicebroadcast.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.recording.usecase.PauseVoiceBroadcastUseCase import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoomService import im.vector.app.test.fakes.FakeSession diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt index a1bc3a04ec..8b66d45dd4 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt @@ -17,9 +17,10 @@ package im.vector.app.features.voicebroadcast.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.recording.usecase.ResumeVoiceBroadcastUseCase import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoomService import im.vector.app.test.fakes.FakeSession diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt index 217a395076..59929ef0d7 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt @@ -17,10 +17,11 @@ package im.vector.app.features.voicebroadcast.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.recording.usecase.StartVoiceBroadcastUseCase import im.vector.app.test.fakes.FakeContext import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoomService diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCaseTest.kt index ee6b141bd9..4b15f50be9 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCaseTest.kt @@ -17,9 +17,10 @@ package im.vector.app.features.voicebroadcast.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.recording.usecase.StopVoiceBroadcastUseCase import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoomService import im.vector.app.test.fakes.FakeSession From 3fcac097d38a27f12eab252ab0469e793e34ac40 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Thu, 27 Oct 2022 16:26:13 +0200 Subject: [PATCH 32/41] VoiceBroadcastPlayer - Fetch playlist in dedicated use case and improve player --- .../listening/VoiceBroadcastPlayerImpl.kt | 130 ++++++------------ .../GetLiveVoiceBroadcastChunksUseCase.kt | 130 ++++++++++++++++++ 2 files changed, 174 insertions(+), 86 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt index 168b921c2e..9afe428e59 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt @@ -23,53 +23,42 @@ import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.voice.VoiceFailure import im.vector.app.features.voicebroadcast.getVoiceBroadcastChunk -import im.vector.app.features.voicebroadcast.getVoiceBroadcastEventId -import im.vector.app.features.voicebroadcast.isVoiceBroadcast import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.Listener import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.State +import im.vector.app.features.voicebroadcast.listening.usecase.GetLiveVoiceBroadcastChunksUseCase import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState -import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.app.features.voicebroadcast.sequence import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastUseCase import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.matrix.android.sdk.api.session.events.model.RelationType -import org.matrix.android.sdk.api.session.getRoom -import org.matrix.android.sdk.api.session.room.Room import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent -import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent -import org.matrix.android.sdk.api.session.room.timeline.Timeline -import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings import timber.log.Timber import java.util.concurrent.CopyOnWriteArrayList import javax.inject.Inject +import javax.inject.Singleton @Singleton class VoiceBroadcastPlayerImpl @Inject constructor( private val sessionHolder: ActiveSessionHolder, private val playbackTracker: AudioMessagePlaybackTracker, private val getVoiceBroadcastUseCase: GetVoiceBroadcastUseCase, + private val getLiveVoiceBroadcastChunksUseCase: GetLiveVoiceBroadcastChunksUseCase ) : VoiceBroadcastPlayer { + private val session get() = sessionHolder.getActiveSession() private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var voiceBroadcastStateJob: Job? = null - private var currentTimeline: Timeline? = null - set(value) { - field?.removeAllListeners() - field?.dispose() - field = value - } private val mediaPlayerListener = MediaPlayerListener() - private var timelineListener: TimelineListener? = null private var currentMediaPlayer: MediaPlayer? = null private var nextMediaPlayer: MediaPlayer? = null @@ -79,7 +68,10 @@ class VoiceBroadcastPlayerImpl @Inject constructor( } private var currentSequence: Int? = null + private var fetchPlaylistJob: Job? = null private var playlist = emptyList() + private var isLive: Boolean = false + override var currentVoiceBroadcastId: String? = null override var playingState = State.IDLE @@ -118,6 +110,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor( // Stop playback currentMediaPlayer?.stop() currentVoiceBroadcastId?.let { playbackTracker.stopPlayback(it) } + isLive = false // Release current player release(currentMediaPlayer) @@ -131,9 +124,9 @@ class VoiceBroadcastPlayerImpl @Inject constructor( voiceBroadcastStateJob?.cancel() voiceBroadcastStateJob = null - // In case of live broadcast, stop observing new chunks - currentTimeline = null - timelineListener = null + // Do not fetch the playlist anymore + fetchPlaylistJob?.cancel() + fetchPlaylistJob = null // Update state playingState = State.IDLE @@ -141,13 +134,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor( // Clear playlist playlist = emptyList() currentSequence = null + currentRoomId = null currentVoiceBroadcastId = null } - /** - * Add a [Listener] to the given voice broadcast id. - */ override fun addListener(voiceBroadcastId: String, listener: Listener) { listeners[voiceBroadcastId]?.add(listener) ?: run { listeners[voiceBroadcastId] = CopyOnWriteArrayList().apply { add(listener) } @@ -155,15 +146,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor( if (voiceBroadcastId == currentVoiceBroadcastId) listener.onStateChanged(playingState) else listener.onStateChanged(State.IDLE) } - /** - * Remove a [Listener] from the given voice broadcast id. - */ override fun removeListener(voiceBroadcastId: String, listener: Listener) { listeners[voiceBroadcastId]?.remove(listener) } private fun startPlayback(roomId: String, eventId: String) { - val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") // Stop listening previous voice broadcast if any if (playingState != State.IDLE) stop() @@ -173,16 +160,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor( playingState = State.BUFFERING val voiceBroadcastState = getVoiceBroadcastUseCase.execute(roomId, eventId)?.content?.voiceBroadcastState - if (voiceBroadcastState == VoiceBroadcastState.STOPPED) { - // Get static playlist - updatePlaylist(getExistingChunks(room, eventId)) - startPlayback(false) - } else { - playLiveVoiceBroadcast(room, eventId) - } + isLive = voiceBroadcastState != null && voiceBroadcastState != VoiceBroadcastState.STOPPED + observeIncomingEvents(roomId, eventId) } - private fun startPlayback(isLive: Boolean) { + private fun startPlayback() { val event = if (isLive) playlist.lastOrNull() else playlist.firstOrNull() val content = event?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return } val sequence = event.getVoiceBroadcastChunk()?.sequence @@ -201,24 +183,10 @@ class VoiceBroadcastPlayerImpl @Inject constructor( } } - private fun playLiveVoiceBroadcast(room: Room, eventId: String) { - room.timelineService().getTimelineEvent(eventId)?.root?.asVoiceBroadcastEvent() ?: error("Cannot retrieve voice broadcast $eventId") - updatePlaylist(getExistingChunks(room, eventId)) - startPlayback(true) - observeIncomingEvents(room, eventId) - } - - private fun getExistingChunks(room: Room, eventId: String): List { - return room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, eventId) - .mapNotNull { it.root.asMessageAudioEvent() } - .filter { it.isVoiceBroadcast() } - } - - private fun observeIncomingEvents(room: Room, eventId: String) { - currentTimeline = room.timelineService().createTimeline(null, TimelineSettings(5)).also { timeline -> - timelineListener = TimelineListener(eventId).also { timeline.addListener(it) } - timeline.start() - } + private fun observeIncomingEvents(roomId: String, voiceBroadcastId: String) { + fetchPlaylistJob = getLiveVoiceBroadcastChunksUseCase.execute(roomId, voiceBroadcastId) + .onEach(this::updatePlaylist) + .launchIn(coroutineScope) } private fun resumePlayback() { @@ -229,11 +197,32 @@ class VoiceBroadcastPlayerImpl @Inject constructor( private fun updatePlaylist(playlist: List) { this.playlist = playlist.sortedBy { it.getVoiceBroadcastChunk()?.sequence?.toLong() ?: it.root.originServerTs } + onPlaylistUpdated() + } + + private fun onPlaylistUpdated() { + when (playingState) { + State.PLAYING -> { + if (nextMediaPlayer == null) { + coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } + } + } + State.PAUSED -> { + if (nextMediaPlayer == null) { + coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } + } + } + State.BUFFERING -> { + val newMediaContent = getNextAudioContent() + if (newMediaContent != null) startPlayback() + } + State.IDLE -> startPlayback() + } } private fun getNextAudioContent(): MessageAudioContent? { val nextSequence = currentSequence?.plus(1) - ?: timelineListener?.let { playlist.lastOrNull()?.sequence } + ?: playlist.lastOrNull()?.sequence ?: 1 return playlist.find { it.getVoiceBroadcastChunk()?.sequence == nextSequence }?.content } @@ -279,37 +268,6 @@ class VoiceBroadcastPlayerImpl @Inject constructor( } } - private inner class TimelineListener(private val voiceBroadcastId: String) : Timeline.Listener { - override fun onTimelineUpdated(snapshot: List) { - val currentSequences = playlist.map { it.sequence } - val newChunks = snapshot - .mapNotNull { timelineEvent -> - timelineEvent.root.asMessageAudioEvent() - ?.takeIf { it.isVoiceBroadcast() && it.getVoiceBroadcastEventId() == voiceBroadcastId && it.sequence !in currentSequences } - } - if (newChunks.isEmpty()) return - updatePlaylist(playlist + newChunks) - - when (playingState) { - State.PLAYING -> { - if (nextMediaPlayer == null) { - coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } - } - } - State.PAUSED -> { - if (nextMediaPlayer == null) { - coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } - } - } - State.BUFFERING -> { - val newMediaContent = getNextAudioContent() - if (newMediaContent != null) startPlayback(true) - } - State.IDLE -> startPlayback(true) - } - } - } - private inner class MediaPlayerListener : MediaPlayer.OnInfoListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener { override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean { @@ -329,7 +287,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor( val roomId = currentRoomId ?: return val voiceBroadcastId = currentVoiceBroadcastId ?: return val voiceBroadcastEventContent = getVoiceBroadcastUseCase.execute(roomId, voiceBroadcastId)?.content ?: return - val isLive = voiceBroadcastEventContent.voiceBroadcastState != null && voiceBroadcastEventContent.voiceBroadcastState != VoiceBroadcastState.STOPPED + isLive = voiceBroadcastEventContent.voiceBroadcastState != null && voiceBroadcastEventContent.voiceBroadcastState != VoiceBroadcastState.STOPPED if (!isLive && voiceBroadcastEventContent.lastChunkSequence == currentSequence) { // We'll not receive new chunks anymore so we can stop the live listening diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt new file mode 100644 index 0000000000..8fbd32767d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2022 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.voicebroadcast.listening.usecase + +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.voicebroadcast.getVoiceBroadcastEventId +import im.vector.app.features.voicebroadcast.isVoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.sequence +import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastUseCase +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.runningReduce +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent +import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import javax.inject.Inject + +/** + * Get a [Flow] of [MessageAudioEvent]s related to the given voice broadcast. + */ +class GetLiveVoiceBroadcastChunksUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val getVoiceBroadcastUseCase: GetVoiceBroadcastUseCase, +) { + + fun execute(roomId: String, voiceBroadcastId: String): Flow> { + val session = activeSessionHolder.getSafeActiveSession() ?: return emptyFlow() + val room = session.roomService().getRoom(roomId) ?: return emptyFlow() + val timeline = room.timelineService().createTimeline(null, TimelineSettings(5)) + + // Get initial chunks + val existingChunks = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcastId) + .mapNotNull { timelineEvent -> timelineEvent.root.asMessageAudioEvent().takeIf { it.isVoiceBroadcast() } } + + val voiceBroadcastEvent = getVoiceBroadcastUseCase.execute(roomId, voiceBroadcastId) + val voiceBroadcastState = voiceBroadcastEvent?.content?.voiceBroadcastState + + return if (voiceBroadcastState == null || voiceBroadcastState == VoiceBroadcastState.STOPPED) { + // Just send the existing chunks if voice broadcast is stopped + flowOf(existingChunks) + } else { + // Observe new timeline events if voice broadcast is ongoing + callbackFlow { + // Init with existing chunks + send(existingChunks) + + // Observe new timeline events + val listener = object : Timeline.Listener { + private var lastEventId: String? = null + private var lastSequence: Int? = null + + override fun onTimelineUpdated(snapshot: List) { + val newEvents = lastEventId?.let { eventId -> snapshot.subList(0, snapshot.indexOfFirst { it.eventId == eventId }) } ?: snapshot + + // Detect a potential stopped voice broadcast state event + val stopEvent = newEvents.findStopEvent() + if (stopEvent != null) { + lastSequence = stopEvent.content?.lastChunkSequence + } + + val newChunks = newEvents.mapToChunkEvents(voiceBroadcastId, voiceBroadcastEvent.root.senderId) + + // Notify about new chunks + if (newChunks.isNotEmpty()) { + trySend(newChunks) + } + + // Automatically stop observing the timeline if the last chunk has been received + if (lastSequence != null && newChunks.any { it.sequence == lastSequence }) { + timeline.removeListener(this) + timeline.dispose() + } + + lastEventId = snapshot.firstOrNull()?.eventId + } + } + + timeline.addListener(listener) + timeline.start() + awaitClose { + timeline.removeListener(listener) + timeline.dispose() + } + } + .runningReduce { accumulator: List, value: List -> accumulator.plus(value) } + } + } + + /** + * Find a [VoiceBroadcastEvent] with a [VoiceBroadcastState.STOPPED] state. + */ + private fun List.findStopEvent(): VoiceBroadcastEvent? = + this.mapNotNull { it.root.asVoiceBroadcastEvent() } + .find { it.content?.voiceBroadcastState == VoiceBroadcastState.STOPPED } + + /** + * Transform the list of [TimelineEvent] to a mapped list of [MessageAudioEvent] related to a given voice broadcast. + */ + private fun List.mapToChunkEvents(voiceBroadcastId: String, senderId: String?): List = + this.mapNotNull { timelineEvent -> + timelineEvent.root.asMessageAudioEvent() + ?.takeIf { + it.isVoiceBroadcast() && it.getVoiceBroadcastEventId() == voiceBroadcastId && + it.root.senderId == senderId + } + } +} From 62c574b96634709a456615ef71ac143186c545e7 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 28 Oct 2022 10:29:48 +0200 Subject: [PATCH 33/41] Add changelog --- changelog.d/7478.wip | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7478.wip diff --git a/changelog.d/7478.wip b/changelog.d/7478.wip new file mode 100644 index 0000000000..2e6602b16d --- /dev/null +++ b/changelog.d/7478.wip @@ -0,0 +1 @@ +[Voice Broadcast] Improve playlist fetching and player codebase From 838e11c167a5bdf9d4b36ef1221f0518029a94b8 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 28 Oct 2022 10:43:05 +0200 Subject: [PATCH 34/41] rename observeIncomingEvents method and reorder some methods --- .../listening/VoiceBroadcastPlayerImpl.kt | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt index 9afe428e59..3999a0e0af 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt @@ -161,40 +161,15 @@ class VoiceBroadcastPlayerImpl @Inject constructor( val voiceBroadcastState = getVoiceBroadcastUseCase.execute(roomId, eventId)?.content?.voiceBroadcastState isLive = voiceBroadcastState != null && voiceBroadcastState != VoiceBroadcastState.STOPPED - observeIncomingEvents(roomId, eventId) + fetchPlaylistAndStartPlayback(roomId, eventId) } - private fun startPlayback() { - val event = if (isLive) playlist.lastOrNull() else playlist.firstOrNull() - val content = event?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return } - val sequence = event.getVoiceBroadcastChunk()?.sequence - coroutineScope.launch { - try { - currentMediaPlayer = prepareMediaPlayer(content) - currentMediaPlayer?.start() - currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } - currentSequence = sequence - withContext(Dispatchers.Main) { playingState = State.PLAYING } - nextMediaPlayer = prepareNextMediaPlayer() - } catch (failure: Throwable) { - Timber.e(failure, "Unable to start playback") - throw VoiceFailure.UnableToPlay(failure) - } - } - } - - private fun observeIncomingEvents(roomId: String, voiceBroadcastId: String) { + private fun fetchPlaylistAndStartPlayback(roomId: String, voiceBroadcastId: String) { fetchPlaylistJob = getLiveVoiceBroadcastChunksUseCase.execute(roomId, voiceBroadcastId) .onEach(this::updatePlaylist) .launchIn(coroutineScope) } - private fun resumePlayback() { - currentMediaPlayer?.start() - currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } - playingState = State.PLAYING - } - private fun updatePlaylist(playlist: List) { this.playlist = playlist.sortedBy { it.getVoiceBroadcastChunk()?.sequence?.toLong() ?: it.root.originServerTs } onPlaylistUpdated() @@ -220,6 +195,31 @@ class VoiceBroadcastPlayerImpl @Inject constructor( } } + private fun startPlayback() { + val event = if (isLive) playlist.lastOrNull() else playlist.firstOrNull() + val content = event?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return } + val sequence = event.getVoiceBroadcastChunk()?.sequence + coroutineScope.launch { + try { + currentMediaPlayer = prepareMediaPlayer(content) + currentMediaPlayer?.start() + currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } + currentSequence = sequence + withContext(Dispatchers.Main) { playingState = State.PLAYING } + nextMediaPlayer = prepareNextMediaPlayer() + } catch (failure: Throwable) { + Timber.e(failure, "Unable to start playback") + throw VoiceFailure.UnableToPlay(failure) + } + } + } + + private fun resumePlayback() { + currentMediaPlayer?.start() + currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } + playingState = State.PLAYING + } + private fun getNextAudioContent(): MessageAudioContent? { val nextSequence = currentSequence?.plus(1) ?: playlist.lastOrNull()?.sequence From 362696cfc88da4a67d4a527fccc9917d6508f124 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 28 Oct 2022 10:27:05 +0200 Subject: [PATCH 35/41] VoiceBroadcast - Show error dialog if user is not able to record a voice broadcast --- .../src/main/res/values/strings.xml | 4 +++ .../vector/app/core/error/ErrorFormatter.kt | 11 +++++++ .../home/room/detail/TimelineFragment.kt | 7 +++- .../home/room/detail/TimelineViewModel.kt | 7 +++- .../voicebroadcast/VoiceBroadcastFailure.kt | 25 +++++++++++++++ .../usecase/StartVoiceBroadcastUseCase.kt | 32 ++++++++++++++++--- 6 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastFailure.kt diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index 9edd7d836a..b5abefec94 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -3085,6 +3085,10 @@ Play or resume voice broadcast Pause voice broadcast Buffering + Can’t start a new voice broadcast + You don’t have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions. + Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one. + You are already recording a voice broadcast. Please end your current voice broadcast to start a new one. Anyone in %s will be able to find and join this room - no need to manually invite everyone. You’ll be able to change this in room settings anytime. Anyone in a parent space will be able to find and join this room - no need to manually invite everyone. You’ll be able to change this in room settings anytime. diff --git a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt index a09f852958..380c80775b 100644 --- a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt +++ b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt @@ -21,6 +21,8 @@ import im.vector.app.R import im.vector.app.core.resources.StringProvider import im.vector.app.features.call.dialpad.DialPadLookup import im.vector.app.features.voice.VoiceFailure +import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure +import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure.RecordingError import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.MatrixIdFailure @@ -135,6 +137,7 @@ class DefaultErrorFormatter @Inject constructor( is MatrixIdFailure.InvalidMatrixId -> stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id) is VoiceFailure -> voiceMessageError(throwable) + is VoiceBroadcastFailure -> voiceBroadcastMessageError(throwable) is ActivityNotFoundException -> stringProvider.getString(R.string.error_no_external_application_found) else -> throwable.localizedMessage @@ -149,6 +152,14 @@ class DefaultErrorFormatter @Inject constructor( } } + private fun voiceBroadcastMessageError(throwable: VoiceBroadcastFailure): String { + return when (throwable) { + RecordingError.BlockedBySomeoneElse -> stringProvider.getString(R.string.error_voice_broadcast_blocked_by_someone_else_message) + RecordingError.NoPermission -> stringProvider.getString(R.string.error_voice_broadcast_permission_denied_message) + RecordingError.UserAlreadyBroadcasting -> stringProvider.getString(R.string.error_voice_broadcast_already_in_progress_message) + } + } + private fun limitExceededError(error: MatrixError): String { val delay = error.retryAfterMillis diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index 4f51922a62..b259d51947 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -33,6 +33,7 @@ import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView import androidx.activity.addCallback +import androidx.annotation.StringRes import androidx.appcompat.view.menu.MenuBuilder import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat @@ -1320,8 +1321,12 @@ class TimelineFragment : } private fun displayRoomDetailActionFailure(result: RoomDetailViewEvents.ActionFailure) { + @StringRes val titleResId = when(result.action) { + RoomDetailAction.VoiceBroadcastAction.Recording.Start -> R.string.error_voice_broadcast_unauthorized_title + else -> R.string.dialog_title_error + } MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.dialog_title_error) + .setTitle(titleResId) .setMessage(errorFormatter.toHumanReadable(result.throwable)) .setPositiveButton(R.string.ok, null) .show() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 82ad96d645..ac117558be 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -604,7 +604,12 @@ class TimelineViewModel @AssistedInject constructor( if (room == null) return viewModelScope.launch { when (action) { - RoomDetailAction.VoiceBroadcastAction.Recording.Start -> voiceBroadcastHelper.startVoiceBroadcast(room.roomId) + RoomDetailAction.VoiceBroadcastAction.Recording.Start -> { + voiceBroadcastHelper.startVoiceBroadcast(room.roomId).fold( + { _viewEvents.post(RoomDetailViewEvents.ActionSuccess(action)) }, + { _viewEvents.post(RoomDetailViewEvents.ActionFailure(action, it)) }, + ) + } RoomDetailAction.VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId) RoomDetailAction.VoiceBroadcastAction.Recording.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId) RoomDetailAction.VoiceBroadcastAction.Recording.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastFailure.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastFailure.kt new file mode 100644 index 0000000000..76b50c78ab --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastFailure.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 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.voicebroadcast + +sealed class VoiceBroadcastFailure : Throwable() { + sealed class RecordingError : VoiceBroadcastFailure() { + object NoPermission : RecordingError() + object BlockedBySomeoneElse : RecordingError() + object UserAlreadyBroadcasting : RecordingError() + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt index a1a519a656..f6870f859f 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt @@ -24,15 +24,22 @@ import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase import im.vector.lib.multipicker.utils.toMultiPickerAudioType +import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.getStateEvent +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent +import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper import timber.log.Timber import java.io.File import javax.inject.Inject @@ -50,12 +57,27 @@ class StartVoiceBroadcastUseCase @Inject constructor( Timber.d("## StartVoiceBroadcastUseCase: Start voice broadcast requested") - val onGoingVoiceBroadcastEvents = getOngoingVoiceBroadcastsUseCase.execute(roomId) + val powerLevelsHelper = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.IsEmpty) + ?.content + ?.toModel() + ?.let { PowerLevelsHelper(it) } - if (onGoingVoiceBroadcastEvents.isEmpty()) { - startVoiceBroadcast(room) - } else { - Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: currentVoiceBroadcastEvents=$onGoingVoiceBroadcastEvents") + when { + powerLevelsHelper?.isUserAllowedToSend(session.myUserId, true, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO) != true -> { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: no permission") + throw VoiceBroadcastFailure.RecordingError.NoPermission + } + voiceBroadcastRecorder?.state == VoiceBroadcastRecorder.State.Recording || voiceBroadcastRecorder?.state == VoiceBroadcastRecorder.State.Paused -> { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: another voice broadcast") + throw VoiceBroadcastFailure.RecordingError.UserAlreadyBroadcasting + } + getOngoingVoiceBroadcastsUseCase.execute(roomId).isNotEmpty() -> { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: user already broadcasting") + throw VoiceBroadcastFailure.RecordingError.BlockedBySomeoneElse + } + else -> { + startVoiceBroadcast(room) + } } } From b510919d59d92466f29f68d7c6cfd79c0b6e363b Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Fri, 28 Oct 2022 10:53:35 +0200 Subject: [PATCH 36/41] Add changelog --- changelog.d/7485.wip | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7485.wip diff --git a/changelog.d/7485.wip b/changelog.d/7485.wip new file mode 100644 index 0000000000..30cab45d9c --- /dev/null +++ b/changelog.d/7485.wip @@ -0,0 +1 @@ +[Voice Broadcast] Display an error dialog if the user fails to start a voice broadcast From a06efb7abdf2e150cb744ef891dc9567aedd0139 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Mon, 31 Oct 2022 10:58:09 +0100 Subject: [PATCH 37/41] Fix lint issues --- .../im/vector/app/features/home/room/detail/TimelineFragment.kt | 2 +- .../recording/usecase/StartVoiceBroadcastUseCase.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index b259d51947..120e5e22cb 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -1321,7 +1321,7 @@ class TimelineFragment : } private fun displayRoomDetailActionFailure(result: RoomDetailViewEvents.ActionFailure) { - @StringRes val titleResId = when(result.action) { + @StringRes val titleResId = when (result.action) { RoomDetailAction.VoiceBroadcastAction.Recording.Start -> R.string.error_voice_broadcast_unauthorized_title else -> R.string.dialog_title_error } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt index f6870f859f..8a335eccac 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt @@ -21,10 +21,10 @@ import androidx.core.content.FileProvider import im.vector.app.core.resources.BuildMeta import im.vector.app.features.attachments.toContentAttachmentData import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState -import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase import im.vector.lib.multipicker.utils.toMultiPickerAudioType From d7791402b79d63c63566564b95af94e404c0bdc8 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Mon, 31 Oct 2022 15:18:24 +0100 Subject: [PATCH 38/41] Fix unit tests --- .../usecase/StartVoiceBroadcastUseCase.kt | 57 ++++++++++++------- .../usecase/StartVoiceBroadcastUseCaseTest.kt | 29 +++++++--- 2 files changed, 55 insertions(+), 31 deletions(-) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt index 8a335eccac..85f72c09da 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt @@ -28,6 +28,7 @@ import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase import im.vector.lib.multipicker.utils.toMultiPickerAudioType +import org.jetbrains.annotations.VisibleForTesting import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.EventType @@ -57,28 +58,8 @@ class StartVoiceBroadcastUseCase @Inject constructor( Timber.d("## StartVoiceBroadcastUseCase: Start voice broadcast requested") - val powerLevelsHelper = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.IsEmpty) - ?.content - ?.toModel() - ?.let { PowerLevelsHelper(it) } - - when { - powerLevelsHelper?.isUserAllowedToSend(session.myUserId, true, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO) != true -> { - Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: no permission") - throw VoiceBroadcastFailure.RecordingError.NoPermission - } - voiceBroadcastRecorder?.state == VoiceBroadcastRecorder.State.Recording || voiceBroadcastRecorder?.state == VoiceBroadcastRecorder.State.Paused -> { - Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: another voice broadcast") - throw VoiceBroadcastFailure.RecordingError.UserAlreadyBroadcasting - } - getOngoingVoiceBroadcastsUseCase.execute(roomId).isNotEmpty() -> { - Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: user already broadcasting") - throw VoiceBroadcastFailure.RecordingError.BlockedBySomeoneElse - } - else -> { - startVoiceBroadcast(room) - } - } + assertCanStartVoiceBroadcast(room) + startVoiceBroadcast(room) } private suspend fun startVoiceBroadcast(room: Room) { @@ -124,4 +105,36 @@ class StartVoiceBroadcastUseCase @Inject constructor( ) ) } + + private fun assertCanStartVoiceBroadcast(room: Room) { + assertHasEnoughPowerLevels(room) + assertNoOngoingVoiceBroadcast(room) + } + + @VisibleForTesting + fun assertHasEnoughPowerLevels(room: Room) { + val powerLevelsHelper = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.IsEmpty) + ?.content + ?.toModel() + ?.let { PowerLevelsHelper(it) } + + if (powerLevelsHelper?.isUserAllowedToSend(session.myUserId, true, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO) != true) { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: no permission") + throw VoiceBroadcastFailure.RecordingError.NoPermission + } + } + + @VisibleForTesting + fun assertNoOngoingVoiceBroadcast(room: Room) { + when { + voiceBroadcastRecorder?.state == VoiceBroadcastRecorder.State.Recording || voiceBroadcastRecorder?.state == VoiceBroadcastRecorder.State.Paused -> { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: another voice broadcast") + throw VoiceBroadcastFailure.RecordingError.UserAlreadyBroadcasting + } + getOngoingVoiceBroadcastsUseCase.execute(room.roomId).isNotEmpty() -> { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: user already broadcasting") + throw VoiceBroadcastFailure.RecordingError.BlockedBySomeoneElse + } + } + } } diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt index 59929ef0d7..ef78f1c80d 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCaseTest.kt @@ -26,15 +26,17 @@ import im.vector.app.test.fakes.FakeContext import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoomService import im.vector.app.test.fakes.FakeSession -import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every +import io.mockk.justRun import io.mockk.mockk import io.mockk.slot +import io.mockk.spyk import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBe import org.amshove.kluent.shouldBeNull +import org.junit.Before import org.junit.Test import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.Event @@ -51,14 +53,23 @@ class StartVoiceBroadcastUseCaseTest { private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) private val fakeVoiceBroadcastRecorder = mockk(relaxed = true) private val fakeGetOngoingVoiceBroadcastsUseCase = mockk() - private val startVoiceBroadcastUseCase = StartVoiceBroadcastUseCase( - session = fakeSession, - voiceBroadcastRecorder = fakeVoiceBroadcastRecorder, - context = FakeContext().instance, - buildMeta = mockk(), - getOngoingVoiceBroadcastsUseCase = fakeGetOngoingVoiceBroadcastsUseCase, + private val startVoiceBroadcastUseCase = spyk( + StartVoiceBroadcastUseCase( + session = fakeSession, + voiceBroadcastRecorder = fakeVoiceBroadcastRecorder, + context = FakeContext().instance, + buildMeta = mockk(), + getOngoingVoiceBroadcastsUseCase = fakeGetOngoingVoiceBroadcastsUseCase, + ) ) + @Before + fun setup() { + every { fakeRoom.roomId } returns A_ROOM_ID + justRun { startVoiceBroadcastUseCase.assertHasEnoughPowerLevels(fakeRoom) } + every { fakeVoiceBroadcastRecorder.state } returns VoiceBroadcastRecorder.State.Idle + } + @Test fun `given a room id with potential several existing voice broadcast states when calling execute then the voice broadcast is started or not`() = runTest { val cases = VoiceBroadcastState.values() @@ -83,7 +94,7 @@ class StartVoiceBroadcastUseCaseTest { private suspend fun testVoiceBroadcastStarted(voiceBroadcasts: List) { // Given - clearAllMocks() + setup() givenVoiceBroadcasts(voiceBroadcasts) val voiceBroadcastInfoContentInterceptor = slot() coEvery { fakeRoom.stateService().sendStateEvent(any(), any(), capture(voiceBroadcastInfoContentInterceptor)) } coAnswers { AN_EVENT_ID } @@ -106,7 +117,7 @@ class StartVoiceBroadcastUseCaseTest { private suspend fun testVoiceBroadcastNotStarted(voiceBroadcasts: List) { // Given - clearAllMocks() + setup() givenVoiceBroadcasts(voiceBroadcasts) // When From 7ba1052bcf6311547ac0ca9ecc1b09720bd0c9b9 Mon Sep 17 00:00:00 2001 From: Jorge Martin Espinosa Date: Mon, 31 Oct 2022 16:43:01 +0100 Subject: [PATCH 39/41] Fix rich text editor EditText not resizing properly in full screen (#7491) * Fix rich text editor full screen mode * Add changelog * Address review comments. --- changelog.d/7491.bugfix | 1 + .../detail/composer/RichTextComposerLayout.kt | 27 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 changelog.d/7491.bugfix diff --git a/changelog.d/7491.bugfix b/changelog.d/7491.bugfix new file mode 100644 index 0000000000..1a87bd03bd --- /dev/null +++ b/changelog.d/7491.bugfix @@ -0,0 +1 @@ +Fix rich text editor textfield not growing to fill parent on full screen. diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index 2c09f351bb..2d2a4a8cd2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -54,8 +54,9 @@ class RichTextComposerLayout @JvmOverloads constructor( private var currentConstraintSetId: Int = -1 private val animationDuration = 100L + private val maxEditTextLinesWhenCollapsed = 12 - private var isFullScreen = false + private val isFullScreen: Boolean get() = currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_fullscreen var isTextFormattingEnabled = true set(value) { @@ -104,10 +105,10 @@ class RichTextComposerLayout @JvmOverloads constructor( collapse(false) views.richTextComposerEditText.addTextChangedListener( - TextChangeListener({ callback?.onTextChanged(it) }, ::updateTextFieldBorder) + TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder() }) ) views.plainTextComposerEditText.addTextChangedListener( - TextChangeListener({ callback?.onTextChanged(it) }, ::updateTextFieldBorder) + TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder() }) ) views.composerRelatedMessageCloseButton.setOnClickListener { @@ -196,8 +197,9 @@ class RichTextComposerLayout @JvmOverloads constructor( button.isSelected = menuState.reversedActions.contains(action) } - private fun updateTextFieldBorder(isExpanded: Boolean) { - val borderResource = if (isExpanded) { + private fun updateTextFieldBorder() { + val isExpanded = editText.editableText.lines().count() > 1 + val borderResource = if (isExpanded || isFullScreen) { R.drawable.bg_composer_rich_edit_text_expanded } else { R.drawable.bg_composer_rich_edit_text_single_line @@ -240,8 +242,21 @@ class RichTextComposerLayout @JvmOverloads constructor( it.applyTo(this) } - updateTextFieldBorder(newValue) + updateTextFieldBorder() updateEditTextVisibility() + + updateEditTextFullScreenState(views.richTextComposerEditText, newValue) + updateEditTextFullScreenState(views.plainTextComposerEditText, newValue) + } + + private fun updateEditTextFullScreenState(editText: EditText, isFullScreen: Boolean) { + if (isFullScreen) { + editText.maxLines = Int.MAX_VALUE + // This is a workaround to fix incorrect scroll position when maximised + post { editText.requestLayout() } + } else { + editText.maxLines = maxEditTextLinesWhenCollapsed + } } private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { From 20abef26b0a6eda4ef7d600072a64f4587cffba0 Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Mon, 31 Oct 2022 18:19:21 +0100 Subject: [PATCH 40/41] Filter duplicated events in live voice broadcasts --- .../listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt index 8fbd32767d..4f9f2de673 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.runningReduce import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent @@ -106,6 +107,7 @@ class GetLiveVoiceBroadcastChunksUseCase @Inject constructor( } } .runningReduce { accumulator: List, value: List -> accumulator.plus(value) } + .map { events -> events.distinctBy { it.sequence } } } } From 68062911a98da9e23c1f7c0b47b6a166973eee8f Mon Sep 17 00:00:00 2001 From: Florian Renaud Date: Tue, 1 Nov 2022 18:17:23 +0100 Subject: [PATCH 41/41] Changelog --- changelog.d/7502.bugfix | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/7502.bugfix diff --git a/changelog.d/7502.bugfix b/changelog.d/7502.bugfix new file mode 100644 index 0000000000..8785310498 --- /dev/null +++ b/changelog.d/7502.bugfix @@ -0,0 +1 @@ +Voice Broadcast - Fix duplicated voice messages in the internal playlist