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
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/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
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
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/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
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
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/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
diff --git a/dependencies.gradle b/dependencies.gradle
index 33a2096a43..47f1097446 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -98,7 +98,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/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-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml
index 897c2853d8..2ea209a8f0 100644
--- a/library/ui-strings/src/main/res/values/strings.xml
+++ b/library/ui-strings/src/main/res/values/strings.xml
@@ -3094,6 +3094,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.
@@ -3222,6 +3226,7 @@
Location
Camera
Contact
+ Text formatting
Show less
@@ -3442,5 +3447,6 @@
Apply italic format
Apply strikethrough format
Apply underline format
+ Toggle full screen mode
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/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/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml
index b7401079e4..d28312ac1c 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/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/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/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/attachments/AttachmentTypeSelectorBottomSheet.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt
index af17800455..f8d5d768ef 100644
--- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt
+++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt
@@ -23,10 +23,10 @@ import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
-import com.airbnb.mvrx.fragmentViewModel
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
@@ -34,7 +34,7 @@ import im.vector.app.features.home.room.detail.TimelineViewModel
@AndroidEntryPoint
class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment() {
- private val viewModel: AttachmentTypeSelectorViewModel by fragmentViewModel()
+ private val viewModel: AttachmentTypeSelectorViewModel by parentFragmentViewModel()
private val timelineViewModel: TimelineViewModel by parentFragmentViewModel()
private val sharedActionViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels(
ownerProducer = { requireParentFragment() }
@@ -51,6 +51,14 @@ class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment onTextFormattingToggled(isChecked) }
}
private fun onAttachmentSelected(attachmentType: AttachmentType) {
@@ -71,6 +80,9 @@ class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment(initialState) {
+ private val vectorPreferences: VectorPreferences,
+) : VectorViewModel(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory {
override fun create(initialState: AttachmentTypeSelectorViewState): AttachmentTypeSelectorViewModel
@@ -39,8 +41,8 @@ class AttachmentTypeSelectorViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory()
- override fun handle(action: EmptyAction) {
- // do nothing
+ override fun handle(action: AttachmentTypeSelectorAction) = when (action) {
+ is AttachmentTypeSelectorAction.ToggleTextFormatting -> setTextFormattingEnabled(action.isEnabled)
}
init {
@@ -48,6 +50,16 @@ class AttachmentTypeSelectorViewModel @AssistedInject constructor(
copy(
isLocationVisible = vectorFeatures.isLocationSharingEnabled(),
isVoiceBroadcastVisible = vectorFeatures.isVoiceBroadcastEnabled(),
+ isTextFormattingEnabled = vectorPreferences.isTextFormattingEnabled(),
+ )
+ }
+ }
+
+ private fun setTextFormattingEnabled(isEnabled: Boolean) {
+ vectorPreferences.setTextFormattingEnabled(isEnabled)
+ setState {
+ copy(
+ isTextFormattingEnabled = isEnabled
)
}
}
@@ -56,4 +68,9 @@ class AttachmentTypeSelectorViewModel @AssistedInject constructor(
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/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt
index 61a8e5b79e..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,6 +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.recording.usecase.StopOngoingVoiceBroadcastUseCase
import im.vector.lib.core.utils.compat.getParcelableExtraCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -92,6 +93,7 @@ class HomeActivityViewModel @AssistedInject constructor(
private val analyticsConfig: AnalyticsConfig,
private val releaseNotesPreferencesStore: ReleaseNotesPreferencesStore,
private val vectorFeatures: VectorFeatures,
+ private val stopOngoingVoiceBroadcastUseCase: StopOngoingVoiceBroadcastUseCase,
) : VectorViewModel(initialState) {
@AssistedFactory
@@ -123,6 +125,7 @@ class HomeActivityViewModel @AssistedInject constructor(
observeReleaseNotes()
observeLocalNotificationsSilenced()
initThreadsMigration()
+ viewModelScope.launch { stopOngoingVoiceBroadcastUseCase.execute() }
}
private fun observeReleaseNotes() = withState { state ->
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..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
@@ -32,7 +32,10 @@ import android.view.ViewGroup
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
import androidx.core.graphics.drawable.DrawableCompat
import androidx.core.net.toUri
@@ -64,6 +67,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 +187,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 +343,7 @@ class TimelineFragment :
setupJumpToBottomView()
setupRemoveJitsiWidgetView()
setupLiveLocationIndicator()
+ setupBackPressHandling()
views.includeRoomToolbar.roomToolbarContentView.debouncedClicks {
navigator.openRoomProfile(requireActivity(), timelineArgs.roomId)
@@ -414,6 +421,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 +1048,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)
@@ -1283,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()
@@ -2002,6 +2044,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/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt
index 62f3cad5aa..50bebc81e4 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
@@ -624,7 +624,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/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 2bbd5c3474..aaf63d7f41 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
@@ -43,6 +43,7 @@ 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
@@ -69,6 +70,7 @@ 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
@@ -97,6 +99,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.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
@@ -167,7 +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: AttachmentTypeSelectorSharedActionViewModel by viewModels()
+ private val attachmentViewModel: AttachmentTypeSelectorViewModel by fragmentViewModel()
+ private val attachmentActionsViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels()
private val composer: MessageComposerView get() {
return if (vectorPreferences.isRichTextEditorEnabled()) {
@@ -213,6 +217,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
@@ -226,7 +237,7 @@ class MessageComposerFragment : VectorBaseFragment(), A
}
}
- attachmentViewModel.stream()
+ attachmentActionsViewModel.stream()
.filterIsInstance()
.onEach { onTypeSelected(it.attachmentType) }
.launchIn(lifecycleScope)
@@ -246,7 +257,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()))
@@ -264,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() {
@@ -309,7 +323,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 (vectorPreferences.isRichTextEditorEnabled()) {
AttachmentTypeSelectorBottomSheet.show(childFragmentManager)
@@ -336,8 +350,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() {
@@ -351,6 +369,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))
+ }
}
}
@@ -477,7 +499,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..bf40c18995 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) {
@@ -79,9 +80,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
}
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..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
@@ -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,18 +32,13 @@ 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
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
@@ -56,24 +50,40 @@ 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 val maxEditTextLinesWhenCollapsed = 12
+
+ private val isFullScreen: Boolean get() = currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_fullscreen
+
+ 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
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
@@ -94,21 +104,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()
@@ -124,24 +125,32 @@ class RichTextComposerLayout @JvmOverloads constructor(
callback?.onAddAttachment()
}
+ views.composerFullScreenButton.setOnClickListener {
+ callback?.onFullScreenModeChanged()
+ }
+
setupRichTextMenu()
}
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)
@@ -149,8 +158,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)
@@ -170,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
@@ -180,7 +208,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)?) {
@@ -190,6 +218,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)?) {
@@ -199,41 +228,71 @@ 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) {
+ 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()
+ 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)?) {
// 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
}
+
+ 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/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 5dc601a91a..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
@@ -15,26 +15,25 @@
*/
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.home.room.detail.timeline.TimelineEventController
+import im.vector.app.features.displayname.getBestName
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
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.getUser
+import org.matrix.android.sdk.api.session.getUserOrDefault
import org.matrix.android.sdk.api.util.toMatrixItem
import javax.inject.Inject
@@ -51,81 +50,61 @@ class VoiceBroadcastItemFactory @Inject constructor(
params: TimelineItemFactoryParams,
messageContent: MessageVoiceBroadcastInfoContent,
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 &&
+ messageContent.deviceId == session.sessionParams.deviceId
+
+ 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.event.roomId,
- eventsGroup.groupId,
- highlight,
- callback,
- attributes
- )
+ createRecordingItem(highlight, attributes, voiceBroadcastAttributes)
} else {
- createListeningItem(
- params.event.roomId,
- eventsGroup.groupId,
- mostRecentMessageContent.voiceBroadcastState,
- recorderName,
- highlight,
- callback,
- attributes
- )
+ createListeningItem(highlight, attributes, voiceBroadcastAttributes)
}
}
private fun createRecordingItem(
- roomId: String,
- voiceBroadcastId: String,
highlight: Boolean,
- callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes,
+ voiceBroadcastAttributes: AbsMessageVoiceBroadcastItem.Attributes,
): MessageVoiceBroadcastRecordingItem {
- val roomSummary = session.getRoom(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)
.leftGuideline(avatarSizeProvider.leftGuideline)
- .callback(callback)
}
private fun createListeningItem(
- roomId: String,
- voiceBroadcastId: String,
- voiceBroadcastState: VoiceBroadcastState?,
- broadcasterName: String?,
highlight: Boolean,
- callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes,
+ voiceBroadcastAttributes: AbsMessageVoiceBroadcastItem.Attributes,
): MessageVoiceBroadcastListeningItem {
- val roomSummary = session.getRoom(roomId)?.roomSummary()
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(broadcasterName)
.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 }
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..ba9d582ea4
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt
@@ -0,0 +1,104 @@
+/*
+ * 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.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() {
+
+ @EpoxyAttribute
+ lateinit var voiceBroadcastAttributes: Attributes
+
+ 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
+
+ override fun bind(holder: H) {
+ super.bind(holder)
+ renderHeader(holder)
+ }
+
+ private fun renderHeader(holder: H) {
+ with(holder) {
+ roomItem?.let {
+ avatarRenderer.render(it, roomAvatarImageView)
+ titleText.text = it.displayName
+ }
+ }
+ renderLiveIndicator(holder)
+ renderMetadata(holder)
+ }
+
+ private fun renderLiveIndicator(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)
+ }
+
+ 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 5b58dda4e6..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
@@ -18,56 +18,19 @@ 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.listening.VoiceBroadcastPlayer
+import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
@EpoxyModelClass
-abstract class MessageVoiceBroadcastListeningItem : AbsMessageItem() {
-
- @EpoxyAttribute
- var callback: TimelineEventController.Callback? = null
-
- @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
+abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem() {
private lateinit var playerListener: VoiceBroadcastPlayer.Listener
- override fun isCacheable(): Boolean = false
-
override fun bind(holder: Holder) {
super.bind(holder)
bindVoiceBroadcastItem(holder)
@@ -75,51 +38,20 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageItem
- renderState(holder, state)
+ renderPlayingState(holder, state)
}
- voiceBroadcastPlayer?.addListener(playerListener)
- renderHeader(holder)
- renderLiveIcon(holder)
+ player.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 = recorderName
+ 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
@@ -127,15 +59,15 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageItem {
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.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 {
- attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId))
+ callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId))
}
}
VoiceBroadcastPlayer.State.BUFFERING -> Unit
@@ -143,34 +75,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..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
@@ -17,45 +17,19 @@
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.model.VoiceBroadcastState
+import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
+import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
@EpoxyModelClass
-abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem() {
+abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem() {
- @EpoxyAttribute
- var callback: TimelineEventController.Callback? = null
-
- @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
+ private var recorderListener: VoiceBroadcastRecorder.Listener? = null
override fun bind(holder: Holder) {
super.bind(holder)
@@ -63,73 +37,80 @@ 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)
- 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
-
- liveIndicator.isVisible = true
- liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary))
-
- 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
- liveIndicator.isVisible = false
- }
- }
+ listenersCountMetadata.isVisible = false
+ remainingTimeMetadata.isVisible = false
}
}
+ private fun renderRecordingState(holder: Holder, state: VoiceBroadcastRecorder.State) {
+ when (state) {
+ VoiceBroadcastRecorder.State.Recording -> renderRecordingState(holder)
+ VoiceBroadcastRecorder.State.Paused -> renderPausedState(holder)
+ VoiceBroadcastRecorder.State.Idle -> renderStoppedState(holder)
+ }
+ }
+
+ private fun renderVoiceBroadcastState(holder: Holder) {
+ when (voiceBroadcastState) {
+ VoiceBroadcastState.STARTED,
+ VoiceBroadcastState.RESUMED -> renderRecordingState(holder)
+ VoiceBroadcastState.PAUSED -> renderPausedState(holder)
+ VoiceBroadcastState.STOPPED,
+ null -> renderStoppedState(holder)
+ }
+ }
+
+ private fun renderRecordingState(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 { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) }
+ stopRecordButton.onClick { 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 { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) }
+ stopRecordButton.onClick { 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 { recorder?.removeListener(it) }
+ recorderListener = null
}
override fun getViewStubId() = STUB_ID
- class Holder : AbsMessageItem.Holder(STUB_ID) {
- val liveIndicator by bind(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/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/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/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 62%
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 2c892c8306..3999a0e0af 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,49 +22,43 @@ 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.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 VoiceBroadcastPlayer @Inject constructor(
+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
@@ -74,38 +68,49 @@ class VoiceBroadcastPlayer @Inject constructor(
}
private var currentSequence: Int? = null
+ private var fetchPlaylistJob: Job? = null
private var playlist = emptyList()
- var currentVoiceBroadcastId: String? = null
+ private var isLive: Boolean = false
- private var state: State = State.IDLE
+ override var currentVoiceBroadcastId: String? = null
+
+ override var playingState = State.IDLE
@MainThread
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()
- fun playOrResume(roomId: String, eventId: String) {
- val hasChanged = currentVoiceBroadcastId != eventId
+ /**
+ * Map voiceBroadcastId to listeners.
+ */
+ private val listeners: MutableMap> = mutableMapOf()
+
+ 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) }
+ isLive = false
// Release current player
release(currentMediaPlayer)
@@ -119,50 +124,78 @@ class VoiceBroadcastPlayer @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
- state = State.IDLE
+ playingState = State.IDLE
// Clear playlist
playlist = emptyList()
currentSequence = null
+
currentRoomId = null
currentVoiceBroadcastId = null
}
- fun addListener(listener: Listener) {
- listeners.add(listener)
- listener.onStateChanged(state)
+ override fun addListener(voiceBroadcastId: String, listener: Listener) {
+ listeners[voiceBroadcastId]?.add(listener) ?: run {
+ listeners[voiceBroadcastId] = CopyOnWriteArrayList().apply { add(listener) }
+ }
+ if (voiceBroadcastId == currentVoiceBroadcastId) listener.onStateChanged(playingState) else listener.onStateChanged(State.IDLE)
}
- fun removeListener(listener: Listener) {
- listeners.remove(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) {
- // Get static playlist
- updatePlaylist(getExistingChunks(room, eventId))
- startPlayback(false)
- } else {
- playLiveVoiceBroadcast(room, eventId)
+ isLive = voiceBroadcastState != null && voiceBroadcastState != VoiceBroadcastState.STOPPED
+ fetchPlaylistAndStartPlayback(roomId, eventId)
+ }
+
+ private fun fetchPlaylistAndStartPlayback(roomId: String, voiceBroadcastId: String) {
+ fetchPlaylistJob = getLiveVoiceBroadcastChunksUseCase.execute(roomId, voiceBroadcastId)
+ .onEach(this::updatePlaylist)
+ .launchIn(coroutineScope)
+ }
+
+ 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 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
@@ -172,7 +205,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")
@@ -181,39 +214,15 @@ class VoiceBroadcastPlayer @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 resumePlayback() {
currentMediaPlayer?.start()
currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) }
- state = State.PLAYING
- }
-
- private fun updatePlaylist(playlist: List) {
- this.playlist = playlist.sortedBy { it.getVoiceBroadcastChunk()?.sequence?.toLong() ?: it.root.originServerTs }
+ playingState = State.PLAYING
}
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
}
@@ -259,37 +268,6 @@ class VoiceBroadcastPlayer @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 (state) {
- 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 {
@@ -309,13 +287,13 @@ class VoiceBroadcastPlayer @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
stop()
} else {
- state = State.BUFFERING
+ playingState = State.BUFFERING
}
}
@@ -324,15 +302,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/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt
new file mode 100644
index 0000000000..4f9f2de673
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt
@@ -0,0 +1,132 @@
+/*
+ * 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.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
+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) }
+ .map { events -> events.distinctBy { it.sequence } }
+ }
+ }
+
+ /**
+ * 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
+ }
+ }
+}
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 65%
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 7934d18e36..85f72c09da 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,26 +14,33 @@
* 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.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.model.asVoiceBroadcastEvent
+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
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
@@ -43,6 +50,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,18 +58,8 @@ 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 }
-
- if (onGoingVoiceBroadcastEvents.isEmpty()) {
- startVoiceBroadcast(room)
- } else {
- Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: currentVoiceBroadcastEvents=$onGoingVoiceBroadcastEvents")
- }
+ assertCanStartVoiceBroadcast(room)
+ startVoiceBroadcast(room)
}
private suspend fun startVoiceBroadcast(room: Room) {
@@ -107,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/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt
new file mode 100644
index 0000000000..791409b869
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt
@@ -0,0 +1,65 @@
+/*
+ * 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.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
+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 getOngoingVoiceBroadcastsUseCase: GetOngoingVoiceBroadcastsUseCase,
+ 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 = 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) {
+ voiceBroadcastHelper.stopVoiceBroadcast(room.roomId)
+ return // No need to iterate more as we should not have more than one recording VB
+ }
+ }
+ }
+}
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/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt
new file mode 100644
index 0000000000..ec50618969
--- /dev/null
+++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetOngoingVoiceBroadcastsUseCase.kt
@@ -0,0 +1,49 @@
+/*
+ * 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.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.getRoom
+import timber.log.Timber
+import javax.inject.Inject
+
+class GetOngoingVoiceBroadcastsUseCase @Inject constructor(
+ private val activeSessionHolder: ActiveSessionHolder,
+) {
+
+ fun execute(roomId: String): List {
+ 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")
+
+ 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/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_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/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/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.xml
similarity index 100%
rename from vector/src/main/res/drawable/ic_voice_broadcast_16.xml
rename to vector/src/main/res/drawable/ic_voice_broadcast.xml
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/bottom_sheet_attachment_type_selector.xml b/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml
index 79a60624cf..7a22ab57f8 100644
--- a/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml
+++ b/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml
@@ -1,6 +1,7 @@
@@ -82,5 +83,24 @@
app:tint="?colorPrimary"
app:titleTextColor="?vctr_content_primary" />
+
+
+
+
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..c5afe1eb44 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">
@@ -104,16 +104,41 @@
android:background="@drawable/bg_composer_rich_edit_text_single_line" />
+
+
+
+
+
@@ -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"
@@ -135,13 +136,13 @@
app:layout_constraintEnd_toEndOf="parent" />
+
+
+
+
@@ -173,6 +203,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">
@@ -149,21 +149,49 @@
app:layout_constraintEnd_toEndOf="parent" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..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
@@ -7,25 +7,14 @@
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 +43,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: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..3296134919 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
@@ -7,25 +7,14 @@
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,7 +43,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" />
+
+
+ android:src="@drawable/ic_recording_dot" />
+ android:src="@drawable/ic_stop" />
diff --git a/vector/src/main/res/layout/view_voice_broadcast_metadata.xml b/vector/src/main/res/layout/view_voice_broadcast_metadata.xml
new file mode 100644
index 0000000000..3bc31cd9a0
--- /dev/null
+++ b/vector/src/main/res/layout/view_voice_broadcast_metadata.xml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
diff --git a/vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt b/vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt
index 478f631c06..e20d498a37 100644
--- a/vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt
+++ b/vector/src/test/java/im/vector/app/features/attachments/AttachmentTypeSelectorViewModelTest.kt
@@ -18,7 +18,9 @@ package im.vector.app.features.attachments
import com.airbnb.mvrx.test.MavericksTestRule
import im.vector.app.test.fakes.FakeVectorFeatures
+import im.vector.app.test.fakes.FakeVectorPreferences
import im.vector.app.test.test
+import io.mockk.verifyOrder
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -29,6 +31,7 @@ internal class AttachmentTypeSelectorViewModelTest {
val mavericksTestRule = MavericksTestRule()
private val fakeVectorFeatures = FakeVectorFeatures()
+ private val fakeVectorPreferences = FakeVectorPreferences()
private val initialState = AttachmentTypeSelectorViewState()
@Before
@@ -36,6 +39,7 @@ internal class AttachmentTypeSelectorViewModelTest {
// Disable all features by default
fakeVectorFeatures.givenLocationSharing(isEnabled = false)
fakeVectorFeatures.givenVoiceBroadcast(isEnabled = false)
+ fakeVectorPreferences.givenTextFormatting(isEnabled = false)
}
@Test
@@ -82,10 +86,57 @@ internal class AttachmentTypeSelectorViewModelTest {
.finish()
}
+ @Test
+ fun `given text formatting is enabled, then text formatting option is checked`() {
+ fakeVectorPreferences.givenTextFormatting(isEnabled = true)
+
+ createViewModel()
+ .test()
+ .assertStates(
+ listOf(
+ initialState.copy(
+ isTextFormattingEnabled = true
+ ),
+ )
+ )
+ .finish()
+ }
+
+ @Test
+ fun `when text formatting is changed, then it updates the UI`() {
+ createViewModel()
+ .apply {
+ handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled = true))
+ }
+ .test()
+ .assertStates(
+ listOf(
+ initialState.copy(
+ isTextFormattingEnabled = true
+ ),
+ )
+ )
+ .finish()
+ }
+
+ @Test
+ fun `when text formatting is changed, then it persists the change`() {
+ createViewModel()
+ .apply {
+ handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled = true))
+ handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled = false))
+ }
+ verifyOrder {
+ fakeVectorPreferences.instance.setTextFormattingEnabled(true)
+ fakeVectorPreferences.instance.setTextFormattingEnabled(false)
+ }
+ }
+
private fun createViewModel(): AttachmentTypeSelectorViewModel {
return AttachmentTypeSelectorViewModel(
initialState,
vectorFeatures = fakeVectorFeatures,
+ vectorPreferences = fakeVectorPreferences.instance,
)
}
}
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 9fa6b7a450..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
@@ -17,23 +17,27 @@
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
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.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,13 +52,24 @@ class StartVoiceBroadcastUseCaseTest {
private val fakeRoom = FakeRoom()
private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom))
private val fakeVoiceBroadcastRecorder = mockk(relaxed = true)
- private val startVoiceBroadcastUseCase = StartVoiceBroadcastUseCase(
- fakeSession,
- fakeVoiceBroadcastRecorder,
- FakeContext().instance,
- mockk()
+ private val fakeGetOngoingVoiceBroadcastsUseCase = mockk()
+ 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()
@@ -79,8 +94,8 @@ class StartVoiceBroadcastUseCaseTest {
private suspend fun testVoiceBroadcastStarted(voiceBroadcasts: List) {
// Given
- clearAllMocks()
- givenAVoiceBroadcasts(voiceBroadcasts)
+ setup()
+ givenVoiceBroadcasts(voiceBroadcasts)
val voiceBroadcastInfoContentInterceptor = slot()
coEvery { fakeRoom.stateService().sendStateEvent(any(), any(), capture(voiceBroadcastInfoContentInterceptor)) } coAnswers { AN_EVENT_ID }
@@ -102,8 +117,8 @@ class StartVoiceBroadcastUseCaseTest {
private suspend fun testVoiceBroadcastNotStarted(voiceBroadcasts: List) {
// Given
- clearAllMocks()
- givenAVoiceBroadcasts(voiceBroadcasts)
+ setup()
+ givenVoiceBroadcasts(voiceBroadcasts)
// When
startVoiceBroadcastUseCase.execute(A_ROOM_ID)
@@ -112,7 +127,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,
@@ -122,7 +137,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)
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
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
}