Merge pull request #7455 from vector-im/resilience-rc

Merge branch resilience-rc into develop
This commit is contained in:
Florian Renaud 2022-11-02 18:19:34 +01:00 committed by GitHub
commit 98e0397afd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 2074 additions and 618 deletions

1
changelog.d/7431.bugfix Normal file
View file

@ -0,0 +1 @@
[Voice Broadcast] Do not display the recorder view for a live broadcast started from another session

1
changelog.d/7436.feature Normal file
View file

@ -0,0 +1 @@
Rich text editor: add full screen mode.

1
changelog.d/7448.wip Normal file
View file

@ -0,0 +1 @@
[Voice Broadcast] Improve timeline items factory and handle bad recording state display

1
changelog.d/7450.wip Normal file
View file

@ -0,0 +1 @@
[Voice Broadcast] Stop recording when opening the room after an app restart

1
changelog.d/7452.feature Normal file
View file

@ -0,0 +1 @@
[Rich text editor] Add plain text mode

1
changelog.d/7478.wip Normal file
View file

@ -0,0 +1 @@
[Voice Broadcast] Improve playlist fetching and player codebase

1
changelog.d/7485.wip Normal file
View file

@ -0,0 +1 @@
[Voice Broadcast] Display an error dialog if the user fails to start a voice broadcast

1
changelog.d/7491.bugfix Normal file
View file

@ -0,0 +1 @@
Fix rich text editor textfield not growing to fill parent on full screen.

1
changelog.d/7502.bugfix Normal file
View file

@ -0,0 +1 @@
Voice Broadcast - Fix duplicated voice messages in the internal playlist

View file

@ -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",

View file

@ -2,6 +2,7 @@
<resources>
<string name="ellipsis" translatable="false"></string>
<string name="no_value_placeholder" translatable="false"></string>
<!-- Temporary string -->
<string name="not_implemented" translatable="false">Not implemented yet in ${app_name}</string>

View file

@ -3094,6 +3094,10 @@
<string name="a11y_play_voice_broadcast">Play or resume voice broadcast</string>
<string name="a11y_pause_voice_broadcast">Pause voice broadcast</string>
<string name="a11y_voice_broadcast_buffering">Buffering</string>
<string name="error_voice_broadcast_unauthorized_title">Cant start a new voice broadcast</string>
<string name="error_voice_broadcast_permission_denied_message">You dont have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.</string>
<string name="error_voice_broadcast_blocked_by_someone_else_message">Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.</string>
<string name="error_voice_broadcast_already_in_progress_message">You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.</string>
<string name="upgrade_room_for_restricted">Anyone in %s will be able to find and join this room - no need to manually invite everyone. Youll be able to change this in room settings anytime.</string>
<string name="upgrade_room_for_restricted_no_param">Anyone in a parent space will be able to find and join this room - no need to manually invite everyone. Youll be able to change this in room settings anytime.</string>
@ -3222,6 +3226,7 @@
<string name="attachment_type_selector_location">Location</string>
<string name="attachment_type_selector_camera">Camera</string>
<string name="attachment_type_selector_contact">Contact</string>
<string name="attachment_type_selector_text_formatting">Text formatting</string>
<string name="message_reaction_show_less">Show less</string>
<plurals name="message_reaction_show_more">
@ -3442,5 +3447,6 @@
<string name="rich_text_editor_format_italic">Apply italic format</string>
<string name="rich_text_editor_format_strikethrough">Apply strikethrough format</string>
<string name="rich_text_editor_format_underline">Apply underline format</string>
<string name="rich_text_editor_full_screen_toggle">Toggle full screen mode</string>
</resources>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="VoiceBroadcastMetadataView">
<attr name="metadataIcon" format="reference" />
<attr name="metadataValue" format="string" />
</declare-styleable>
</resources>

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="VoiceBroadcastLiveIndicator" parent="Widget.AppCompat.TextView">
<item name="android:layout_width">wrap_content</item>
<item name="android:layout_height">20dp</item>
<item name="android:backgroundTint">?colorError</item>
<item name="android:drawablePadding">4dp</item>
<item name="android:ellipsize">end</item>
<item name="android:gravity">center_vertical</item>
<item name="android:maxWidth">100dp</item>
<item name="android:paddingEnd">4dp</item>
<item name="android:paddingStart">4dp</item>
<item name="android:singleLine">true</item>
<item name="android:textColor">?colorOnError</item>
<item name="drawableTint">?colorOnError</item>
</style>
</resources>

View file

@ -150,7 +150,8 @@
<activity
android:name=".features.home.room.detail.RoomDetailActivity"
android:parentActivityName=".features.home.HomeActivity">
android:parentActivityName=".features.home.HomeActivity"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="android.support.PARENT_ACTIVITY"
android:value=".features.home.HomeActivity" />

View file

@ -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
}

View file

@ -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

View file

@ -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)
}

View file

@ -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<BottomSheetAttachmentTypeSelectorBinding>() {
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<Bo
views.location.isVisible = viewState.isLocationVisible
views.voiceBroadcast.isVisible = viewState.isVoiceBroadcastVisible
views.poll.isVisible = !timelineState.isThreadTimeline()
views.textFormatting.isChecked = viewState.isTextFormattingEnabled
views.textFormatting.setCompoundDrawablesRelativeWithIntrinsicBounds(
if (viewState.isTextFormattingEnabled) {
R.drawable.ic_text_formatting
} else {
R.drawable.ic_text_formatting_disabled
}, 0, 0, 0
)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@ -63,6 +71,7 @@ class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment<Bo
views.location.debouncedClicks { onAttachmentSelected(AttachmentType.LOCATION) }
views.camera.debouncedClicks { onAttachmentSelected(AttachmentType.CAMERA) }
views.contact.debouncedClicks { onAttachmentSelected(AttachmentType.CONTACT) }
views.textFormatting.setOnCheckedChangeListener { _, isChecked -> onTextFormattingToggled(isChecked) }
}
private fun onAttachmentSelected(attachmentType: AttachmentType) {
@ -71,6 +80,9 @@ class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment<Bo
dismiss()
}
private fun onTextFormattingToggled(isEnabled: Boolean) =
viewModel.handle(AttachmentTypeSelectorAction.ToggleTextFormatting(isEnabled))
companion object {
fun show(fragmentManager: FragmentManager) {
val bottomSheet = AttachmentTypeSelectorBottomSheet()

View file

@ -23,15 +23,17 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.EmptyAction
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.core.platform.VectorViewModelAction
import im.vector.app.features.VectorFeatures
import im.vector.app.features.settings.VectorPreferences
class AttachmentTypeSelectorViewModel @AssistedInject constructor(
@Assisted initialState: AttachmentTypeSelectorViewState,
private val vectorFeatures: VectorFeatures,
) : VectorViewModel<AttachmentTypeSelectorViewState, EmptyAction, EmptyViewEvents>(initialState) {
private val vectorPreferences: VectorPreferences,
) : VectorViewModel<AttachmentTypeSelectorViewState, AttachmentTypeSelectorAction, EmptyViewEvents>(initialState) {
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<AttachmentTypeSelectorViewModel, AttachmentTypeSelectorViewState> {
override fun create(initialState: AttachmentTypeSelectorViewState): AttachmentTypeSelectorViewModel
@ -39,8 +41,8 @@ class AttachmentTypeSelectorViewModel @AssistedInject constructor(
companion object : MavericksViewModelFactory<AttachmentTypeSelectorViewModel, AttachmentTypeSelectorViewState> 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
}

View file

@ -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<HomeActivityViewState, HomeActivityViewActions, HomeActivityViewEvents>(initialState) {
@AssistedFactory
@ -123,6 +125,7 @@ class HomeActivityViewModel @AssistedInject constructor(
observeReleaseNotes()
observeLocalNotificationsSilenced()
initThreadsMigration()
viewModelScope.launch { stopOngoingVoiceBroadcastUseCase.execute() }
}
private fun observeReleaseNotes() = withState { state ->

View file

@ -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 {

View file

@ -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.
*/

View file

@ -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)

View file

@ -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()

View file

@ -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<FragmentComposerBinding>(), 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<FragmentComposerBinding>(), 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<FragmentComposerBinding>(), A
}
}
attachmentViewModel.stream()
attachmentActionsViewModel.stream()
.filterIsInstance<AttachmentTypeSelectorSharedAction.SelectAttachmentTypeAction>()
.onEach { onTypeSelected(it.attachmentType) }
.launchIn(lifecycleScope)
@ -246,7 +257,7 @@ class MessageComposerFragment : VectorBaseFragment<FragmentComposerBinding>(), 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<FragmentComposerBinding>(), 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<FragmentComposerBinding>(), 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<FragmentComposerBinding>(), 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<FragmentComposerBinding>(), 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<FragmentComposerBinding>(), 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
}
}

View file

@ -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()
}

View file

@ -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(),

View file

@ -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
}

View file

@ -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) {

View file

@ -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
}
}
}

View file

@ -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 {

View file

@ -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<out VectorEpoxyHolder>? {
): 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)
}
}

View file

@ -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 }

View file

@ -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<H : AbsMessageVoiceBroadcastItem.Holder> : AbsMessageItem<H>() {
@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<TextView>(R.id.liveIndicator)
val roomAvatarImageView by bind<ImageView>(R.id.roomAvatarImageView)
val titleText by bind<TextView>(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,
)
}

View file

@ -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<MessageVoiceBroadcastListeningItem.Holder>() {
@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<MessageVoiceBroadcastListeningItem.Holder>() {
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<MessageVoiceB
private fun bindVoiceBroadcastItem(holder: Holder) {
playerListener = VoiceBroadcastPlayer.Listener { state ->
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<MessageVoiceB
when (state) {
VoiceBroadcastPlayer.State.PLAYING -> {
playPauseButton.setImageResource(R.drawable.ic_play_pause_pause)
playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast)
playPauseButton.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<MessageVoiceB
}
}
private fun renderInactiveMedia(holder: Holder) {
with(holder) {
bufferingView.isVisible = false
playPauseButton.isVisible = true
playPauseButton.setImageResource(R.drawable.ic_play_pause_play)
playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast)
playPauseButton.onClick {
attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId))
}
}
}
private fun isCurrentMediaActive() = voiceBroadcastPlayer?.currentVoiceBroadcastId == voiceBroadcastId
override fun unbind(holder: Holder) {
super.unbind(holder)
voiceBroadcastPlayer?.removeListener(playerListener)
player.removeListener(voiceBroadcastId, playerListener)
}
override fun getViewStubId() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) {
val liveIndicator by bind<TextView>(R.id.liveIndicator)
val roomAvatarImageView by bind<ImageView>(R.id.roomAvatarImageView)
val titleText by bind<TextView>(R.id.titleText)
class Holder : AbsMessageVoiceBroadcastItem.Holder(STUB_ID) {
val playPauseButton by bind<ImageButton>(R.id.playPauseButton)
val bufferingView by bind<View>(R.id.bufferingView)
val broadcasterNameText by bind<TextView>(R.id.broadcasterNameText)
val broadcasterNameMetadata by bind<VoiceBroadcastMetadataView>(R.id.broadcasterNameMetadata)
val voiceBroadcastMetadata by bind<VoiceBroadcastMetadataView>(R.id.voiceBroadcastMetadata)
val listenersCountMetadata by bind<VoiceBroadcastMetadataView>(R.id.listenersCountMetadata)
}
companion object {

View file

@ -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<MessageVoiceBroadcastRecordingItem.Holder>() {
abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem<MessageVoiceBroadcastRecordingItem.Holder>() {
@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<MessageVoiceB
}
private fun bindVoiceBroadcastItem(holder: Holder) {
recorderListener = object : VoiceBroadcastRecorder.Listener {
override fun onStateUpdated(state: VoiceBroadcastRecorder.State) {
renderState(holder, state)
}
}
voiceBroadcastRecorder?.addListener(recorderListener)
renderHeader(holder)
}
private fun renderHeader(holder: Holder) {
with(holder) {
roomItem?.let {
attributes.avatarRenderer.render(it, roomAvatarImageView)
titleText.text = it.displayName
}
if (recorder != null && recorder?.state != VoiceBroadcastRecorder.State.Idle) {
recorderListener = object : VoiceBroadcastRecorder.Listener {
override fun onStateUpdated(state: VoiceBroadcastRecorder.State) {
renderRecordingState(holder, state)
}
}.also { recorder?.addListener(it) }
} else {
renderVoiceBroadcastState(holder)
}
}
private fun renderState(holder: Holder, state: VoiceBroadcastRecorder.State) {
override fun renderMetadata(holder: Holder) {
with(holder) {
when (state) {
VoiceBroadcastRecorder.State.Recording -> {
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<TextView>(R.id.liveIndicator)
val roomAvatarImageView by bind<ImageView>(R.id.roomAvatarImageView)
val titleText by bind<TextView>(R.id.titleText)
class Holder : AbsMessageVoiceBroadcastItem.Holder(STUB_ID) {
val listenersCountMetadata by bind<VoiceBroadcastMetadataView>(R.id.listenersCountMetadata)
val remainingTimeMetadata by bind<VoiceBroadcastMetadataView>(R.id.remainingTimeMetadata)
val recordButton by bind<ImageButton>(R.id.recordButton)
val stopRecordButton by bind<ImageButton>(R.id.stopRecordButton)
}

View file

@ -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.
*/

View file

@ -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()
}
}

View file

@ -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
/**

View file

@ -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)
}
}

View file

@ -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<MessageAudioEvent>()
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<Listener>()
fun playOrResume(roomId: String, eventId: String) {
val hasChanged = currentVoiceBroadcastId != eventId
/**
* Map voiceBroadcastId to listeners.
*/
private val listeners: MutableMap<String, CopyOnWriteArrayList<Listener>> = 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<Listener>().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<MessageAudioEvent>) {
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<MessageAudioEvent> {
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<MessageAudioEvent>) {
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<TimelineEvent>) {
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)
}
}

View file

@ -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<List<MessageAudioEvent>> {
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<TimelineEvent>) {
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<MessageAudioEvent>, value: List<MessageAudioEvent> -> accumulator.plus(value) }
.map { events -> events.distinctBy { it.sequence } }
}
}
/**
* Find a [VoiceBroadcastEvent] with a [VoiceBroadcastState.STOPPED] state.
*/
private fun List<TimelineEvent>.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<TimelineEvent>.mapToChunkEvents(voiceBroadcastId: String, senderId: String?): List<MessageAudioEvent> =
this.mapNotNull { timelineEvent ->
timelineEvent.root.asMessageAudioEvent()
?.takeIf {
it.isVoiceBroadcast() && it.getVoiceBroadcastEventId() == voiceBroadcastId &&
it.root.senderId == senderId
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<Unit> = 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<PowerLevelsContent>()
?.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
}
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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

View file

@ -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<VoiceBroadcastEvent> {
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 }
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:pathData="M17.125,31.5C16.944,31.5 16.795,31.441 16.677,31.323C16.559,31.205 16.5,31.056 16.5,30.875V25.875C16.5,25.694 16.559,25.545 16.677,25.427C16.795,25.309 16.944,25.25 17.125,25.25C17.306,25.25 17.455,25.309 17.573,25.427C17.691,25.545 17.75,25.694 17.75,25.875V29.375L29.375,17.75H25.875C25.694,17.75 25.545,17.691 25.427,17.573C25.309,17.455 25.25,17.306 25.25,17.125C25.25,16.944 25.309,16.795 25.427,16.677C25.545,16.559 25.694,16.5 25.875,16.5H30.875C31.056,16.5 31.205,16.559 31.323,16.677C31.441,16.795 31.5,16.944 31.5,17.125V22.125C31.5,22.306 31.441,22.455 31.323,22.573C31.205,22.691 31.056,22.75 30.875,22.75C30.694,22.75 30.545,22.691 30.427,22.573C30.309,22.455 30.25,22.306 30.25,22.125V18.625L18.625,30.25H22.125C22.306,30.25 22.455,30.309 22.573,30.427C22.691,30.545 22.75,30.694 22.75,30.875C22.75,31.056 22.691,31.205 22.573,31.323C22.455,31.441 22.306,31.5 22.125,31.5H17.125Z"
android:fillColor="#C1C6CD"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24h-24z"/>
<path
android:pathData="M3,20.667C3,21.4 3.6,22 4.333,22H20.333C21.067,22 21.667,21.4 21.667,20.667C21.667,19.933 21.067,19.333 20.333,19.333H4.333C3.6,19.333 3,19.933 3,20.667ZM9,13.733H15.667L16.547,15.867C16.747,16.347 17.213,16.667 17.733,16.667C18.653,16.667 19.267,15.72 18.907,14.88L13.733,2.92C13.493,2.36 12.947,2 12.333,2C11.72,2 11.173,2.36 10.933,2.92L5.76,14.88C5.4,15.72 6.027,16.667 6.947,16.667C7.467,16.667 7.933,16.347 8.133,15.867L9,13.733ZM12.333,4.64L14.827,11.333H9.84L12.333,4.64Z"
android:fillColor="#0DBD8B"/>
</group>
</vector>

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<group>
<clip-path
android:pathData="M0,0h24v24h-24z"/>
<path
android:pathData="M9,15.733H15.667L16.547,17.867C16.747,18.347 17.213,18.667 17.733,18.667C18.653,18.667 19.267,17.72 18.907,16.88L13.733,4.92C13.493,4.36 12.947,4 12.333,4C11.72,4 11.173,4.36 10.933,4.92L5.76,16.88C5.4,17.72 6.027,18.667 6.947,18.667C7.467,18.667 7.933,18.347 8.133,17.867L9,15.733ZM12.333,6.64L14.827,13.333H9.84L12.333,6.64Z"
android:fillColor="#0DBD8B"/>
<path
android:strokeWidth="1"
android:pathData="M2.5,11.667C2.5,12.676 3.324,13.5 4.333,13.5H20.333C21.343,13.5 22.167,12.676 22.167,11.667C22.167,10.657 21.343,9.833 20.333,9.833H4.333C3.324,9.833 2.5,10.657 2.5,11.667Z"
android:fillColor="#0DBD8B"
android:strokeColor="#ffffff"/>
</group>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M10,1H6V2.333H10V1ZM7.333,9.667H8.667V5.667H7.333V9.667ZM12.687,5.26L13.633,4.313C13.347,3.973 13.033,3.653 12.693,3.373L11.747,4.32C10.713,3.493 9.413,3 8,3C4.687,3 2,5.687 2,9C2,12.313 4.68,15 8,15C11.32,15 14,12.313 14,9C14,7.587 13.507,6.287 12.687,5.26ZM8,13.667C5.42,13.667 3.333,11.58 3.333,9C3.333,6.42 5.42,4.333 8,4.333C10.58,4.333 12.667,6.42 12.667,9C12.667,11.58 10.58,13.667 8,13.667Z"
android:fillColor="#737D8C"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M5.4,4.1C5.4,2.664 6.564,1.5 8,1.5C9.436,1.5 10.6,2.664 10.6,4.1V7.988C10.6,9.424 9.436,10.588 8,10.588C6.564,10.588 5.4,9.424 5.4,7.988V4.1Z"
android:fillColor="#737D8C"/>
<path
android:pathData="M3.45,7.158C3.91,7.158 4.283,7.531 4.283,7.992C4.283,10.037 5.941,11.697 7.99,11.703C7.993,11.703 7.996,11.703 8,11.703C8.003,11.703 8.006,11.703 8.01,11.703C10.059,11.697 11.716,10.037 11.716,7.992C11.716,7.531 12.089,7.158 12.55,7.158C13.01,7.158 13.383,7.531 13.383,7.992C13.383,10.679 11.41,12.905 8.833,13.305V13.834C8.833,14.294 8.46,14.667 8,14.667C7.539,14.667 7.166,14.294 7.166,13.834V13.305C4.59,12.905 2.616,10.679 2.616,7.992C2.616,7.531 2.989,7.158 3.45,7.158Z"
android:fillColor="#737D8C"/>
</vector>

View file

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?colorSurface">
@ -82,5 +83,24 @@
app:tint="?colorPrimary"
app:titleTextColor="?vctr_content_primary" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?vctr_list_separator" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/textFormatting"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:drawableStart="@drawable/ic_text_formatting"
android:drawablePadding="20dp"
android:padding="20dp"
android:paddingStart="28dp"
android:text="@string/attachment_type_selector_text_formatting"
android:textAppearance="@style/TextAppearance.Vector.Subtitle"
android:textColor="?vctr_content_primary"
app:drawableTint="?colorPrimary"
tools:ignore="RtlSymmetry" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View file

@ -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" />
<io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText"
android:id="@+id/richTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
android:gravity="top"
android:nextFocusLeft="@id/richTextComposerEditText"
android:nextFocusUp="@id/richTextComposerEditText"
tools:hint="@string/room_message_placeholder"
tools:text="@tools:sample/lorem/random"
tools:ignore="MissingConstraints" />
<!-- Use a separate EditText for plain text editing while the rich text editor doesn't support this mode -->
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/plainTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:gravity="top"
android:nextFocusLeft="@id/plainTextComposerEditText"
android:nextFocusUp="@id/plainTextComposerEditText"
tools:hint="@string/room_message_placeholder"
tools:text="@tools:sample/lorem/random"
tools:ignore="MissingConstraints" />
<ImageButton
android:id="@+id/composerFullScreenButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toEndOf="@id/composerEditTextOuterBorder"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
android:src="@drawable/ic_composer_full_screen"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/rich_text_editor_full_screen_toggle" />
<ImageButton
android:id="@+id/sendButton"
android:layout_width="0dp"

View file

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/composerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
@ -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" />
<io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText"
android:id="@+id/richTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
android:nextFocusLeft="@id/richTextComposerEditText"
android:nextFocusUp="@id/richTextComposerEditText"
android:layout_marginHorizontal="12dp"
android:layout_marginVertical="10dp"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
@ -150,6 +151,34 @@
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
tools:text="@tools:sample/lorem/random" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/plainTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/plainTextComposerEditText"
android:nextFocusUp="@id/plainTextComposerEditText"
android:layout_marginStart="12dp"
android:layout_marginVertical="10dp"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toStartOf="@id/composerFullScreenButton"
app:layout_constraintStart_toStartOf="@id/composerEditTextOuterBorder"
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
tools:text="@tools:sample/lorem/random" />
<ImageButton
android:id="@+id/composerFullScreenButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toEndOf="@id/composerEditTextOuterBorder"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
app:layout_constraintVertical_bias="0"
android:src="@drawable/ic_composer_full_screen"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/rich_text_editor_full_screen_toggle" />
<ImageButton
android:id="@+id/sendButton"
android:layout_width="56dp"
@ -163,6 +192,7 @@
app:layout_constraintTop_toBottomOf="@id/composerEditTextOuterBorder"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="1"
tools:ignore="MissingPrefix"
tools:visibility="visible" />
@ -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">
<LinearLayout

View file

@ -4,7 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/composerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
@ -149,21 +149,49 @@
app:layout_constraintEnd_toEndOf="parent" />
<io.element.android.wysiwyg.EditorEditText
android:id="@+id/composerEditText"
android:id="@+id/richTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/composerEditText"
android:nextFocusUp="@id/composerEditText"
android:layout_marginHorizontal="12dp"
android:nextFocusLeft="@id/richTextComposerEditText"
android:nextFocusUp="@id/richTextComposerEditText"
android:layout_marginStart="12dp"
android:layout_marginVertical="10dp"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toEndOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toStartOf="@id/composerFullScreenButton"
app:layout_constraintStart_toStartOf="@id/composerEditTextOuterBorder"
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
tools:text="@tools:sample/lorem/random" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/plainTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/plainTextComposerEditText"
android:nextFocusUp="@id/plainTextComposerEditText"
android:layout_marginStart="12dp"
android:layout_marginVertical="10dp"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toStartOf="@id/composerFullScreenButton"
app:layout_constraintStart_toStartOf="@id/composerEditTextOuterBorder"
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
tools:text="@tools:sample/lorem/random" />
<ImageButton
android:id="@+id/composerFullScreenButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toEndOf="@id/composerEditTextOuterBorder"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
app:layout_constraintVertical_bias="0"
android:src="@drawable/ic_composer_full_screen"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/rich_text_editor_full_screen_toggle" />
<ImageButton
android:id="@+id/sendButton"
android:layout_width="56dp"

View file

@ -0,0 +1,234 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/composerLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<View
android:id="@+id/related_message_background"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?colorSurface"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:layout_height="40dp" />
<View
android:id="@+id/related_message_background_top_separator"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?vctr_list_separator"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageView
android:id="@+id/composerRelatedMessageAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:importantForAccessibility="no"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toStartOf="parent" />
<TextView
android:id="@+id/composerRelatedMessageTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textStyle="bold"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@id/composerRelatedMessageContent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="@tools:sample/first_names" />
<TextView
android:id="@+id/composerRelatedMessageContent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="@tools:sample/lorem/random" />
<ImageView
android:id="@+id/composerRelatedMessageActionIcon"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="38dp"
android:alpha="0"
android:importantForAccessibility="no"
app:layout_constraintEnd_toStartOf="parent"
app:layout_constraintTop_toBottomOf="parent"
app:tint="?vctr_content_primary"
tools:ignore="MissingConstraints,MissingPrefix"
tools:src="@drawable/ic_edit" />
<ImageView
android:id="@+id/composerRelatedMessageImage"
android:layout_width="0dp"
android:layout_height="0dp"
android:importantForAccessibility="no"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintStart_toEndOf="parent"
tools:ignore="MissingPrefix"
tools:src="@tools:sample/backgrounds/scenic" />
<ImageButton
android:id="@+id/composerRelatedMessageCloseButton"
android:layout_width="22dp"
android:layout_height="22dp"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/action_cancel"
android:src="@drawable/ic_close_round"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="parent"
app:layout_constraintStart_toEndOf="parent"
app:tint="?colorError"
tools:ignore="MissingPrefix"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/composer_preview_barrier"
android:layout_width="0dp"
android:layout_height="0dp"
app:barrierDirection="bottom"
app:barrierMargin="8dp"
app:constraint_referenced_ids="composerRelatedMessageContent,composerRelatedMessageActionIcon"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ImageButton
android:id="@+id/attachmentButton"
android:layout_width="@dimen/composer_attachment_size"
android:layout_height="@dimen/composer_attachment_size"
android:layout_margin="@dimen/composer_attachment_margin"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/option_send_files"
android:src="@drawable/ic_attachment"
app:layout_constraintBottom_toBottomOf="@id/sendButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/sendButton"
app:layout_goneMarginBottom="57dp"
app:layout_constraintVertical_bias="1"
tools:ignore="MissingPrefix" />
<FrameLayout
android:id="@+id/composerEditTextOuterBorder"
android:layout_width="0dp"
android:layout_height="0dp"
android:minHeight="40dp"
android:layout_marginTop="16dp"
android:layout_marginHorizontal="12dp"
android:background="@drawable/bg_composer_rich_edit_text_expanded"
app:layout_constraintVertical_bias="0"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@id/sendButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<io.element.android.wysiwyg.EditorEditText
android:id="@+id/richTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="0dp"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/richTextComposerEditText"
android:nextFocusUp="@id/richTextComposerEditText"
android:layout_marginStart="12dp"
android:layout_marginVertical="10dp"
android:gravity="top"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toStartOf="@id/composerFullScreenButton"
app:layout_constraintStart_toStartOf="@id/composerEditTextOuterBorder"
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
tools:text="@tools:sample/lorem/random" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/plainTextComposerEditText"
style="@style/Widget.Vector.EditText.RichTextComposer"
android:layout_width="0dp"
android:layout_height="0dp"
android:hint="@string/room_message_placeholder"
android:nextFocusLeft="@id/plainTextComposerEditText"
android:nextFocusUp="@id/plainTextComposerEditText"
android:layout_marginStart="12dp"
android:layout_marginVertical="10dp"
android:gravity="top"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toStartOf="@id/composerFullScreenButton"
app:layout_constraintStart_toStartOf="@id/composerEditTextOuterBorder"
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
tools:text="@tools:sample/lorem/random" />
<ImageButton
android:id="@+id/composerFullScreenButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder"
app:layout_constraintEnd_toEndOf="@id/composerEditTextOuterBorder"
app:layout_constraintBottom_toBottomOf="@id/composerEditTextOuterBorder"
app:layout_constraintVertical_bias="0"
android:src="@drawable/ic_composer_full_screen"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/rich_text_editor_full_screen_toggle" />
<ImageButton
android:id="@+id/sendButton"
android:layout_width="56dp"
android:layout_height="@dimen/composer_min_height"
android:layout_marginEnd="2dp"
android:background="@drawable/bg_send"
android:contentDescription="@string/action_send"
android:scaleType="center"
android:src="@drawable/ic_send"
android:visibility="invisible"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintVertical_bias="1"
tools:ignore="MissingPrefix"
tools:visibility="visible" />
<HorizontalScrollView android:id="@+id/richTextMenuScrollView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="@id/sendButton"
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">
<LinearLayout
android:id="@+id/richTextMenu"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
</LinearLayout>
</HorizontalScrollView>
<!--
<ImageButton
android:id="@+id/voiceMessageMicButton"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_marginEnd="12dp"
android:layout_marginBottom="12dp"
android:background="?android:attr/selectableItemBackground"
android:contentDescription="@string/a11y_start_voice_message"
android:src="@drawable/ic_voice_mic"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
-->
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -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">
<im.vector.app.features.home.room.detail.composer.PlainTextComposerLayout
android:id="@+id/composerLayout"
@ -19,7 +19,7 @@
<im.vector.app.features.home.room.detail.composer.RichTextComposerLayout
android:id="@+id/richTextComposerLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_height="match_parent"
android:background="?android:colorBackground"
android:minHeight="56dp"
android:transitionName="composer"

View file

@ -6,6 +6,21 @@
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- ========================
/!\ Constraints for this layout are defined in external layout files that are used as constraint set for animation.
/!\ These 2 files must be modified to stay coherent!
======================== -->
<View android:id="@+id/scrim"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:visibility="gone"
android:background="#44000000" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
@ -98,6 +113,7 @@
android:layout_height="wrap_content"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/bottomBarrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
@ -128,6 +144,7 @@
android:id="@+id/composerContainer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?android:colorBackground"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="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"

View file

@ -0,0 +1,258 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/rootConstraintLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<!-- ========================
/!\ Constraints for this layout are defined in external layout files that are used as constraint set for animation.
/!\ These 2 files must be modified to stay coherent!
======================== -->
<View android:id="@+id/scrim"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
android:translationZ="10dp"
android:visibility="visible"
android:background="#44000000" />
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
<im.vector.app.core.ui.views.CurrentCallsView
android:id="@+id/currentCallsView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:visibility="gone" />
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/roomToolbar"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
android:transitionName="toolbar">
<include
android:id="@+id/includeThreadToolbar"
layout="@layout/view_room_detail_thread_toolbar" />
<include
android:id="@+id/includeRoomToolbar"
layout="@layout/view_room_detail_toolbar" />
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
<im.vector.app.features.sync.widget.SyncStateView
android:id="@+id/syncStateView"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appBarLayout" />
<im.vector.app.features.location.live.LiveLocationStatusView
android:id="@+id/liveLocationStatusIndicator"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/syncStateView"
tools:visibility="visible" />
<im.vector.app.features.call.conference.RemoveJitsiWidgetView
android:id="@+id/removeJitsiWidgetView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="?android:colorBackground"
android:minHeight="54dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/liveLocationStatusIndicator" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/timelineRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:overScrollMode="always"
android:visibility="invisible"
app:layout_constraintBottom_toTopOf="@id/typingMessageView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView"
tools:listitem="@layout/item_timeline_event_base" />
<com.google.android.material.chip.Chip
android:id="@+id/jumpToReadMarkerView"
style="?vctr_jump_to_unread_style"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginTop="24dp"
android:text="@string/room_jump_to_first_unread"
android:visibility="invisible"
app:chipIcon="@drawable/ic_jump_to_unread"
app:chipIconTint="?colorPrimary"
app:closeIcon="@drawable/ic_close_24dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView" />
<im.vector.app.core.ui.views.TypingMessageView
android:id="@+id/typingMessageView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/bottomBarrier"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/timelineRecyclerView" />
<im.vector.app.core.ui.views.NotificationAreaView
android:id="@+id/notificationAreaView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
<ViewStub
android:id="@+id/failedMessagesWarningStub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inflatedId="@+id/failedMessagesWarningStub"
android:layout="@layout/view_stub_failed_message_warning_layout"
app:layout_constraintBottom_toTopOf="@id/composerContainer"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:layout_height="300dp" />
<FrameLayout
android:id="@+id/composerContainer"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?android:colorBackground"
android:translationZ="48dp"
android:layout_marginTop="10dp"
app:layout_constraintTop_toBottomOf="@id/appBarLayout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<FrameLayout
android:id="@+id/voiceMessageRecorderContainer"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:translationZ="48dp"
android:background="?android:colorBackground"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent" />
<ViewStub
android:id="@+id/inviteViewStub"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?android:colorBackground"
android:layout="@layout/view_stub_invite_layout"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/appBarLayout" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/bottomBarrier"
android:layout_width="0dp"
android:layout_height="0dp"
app:barrierDirection="top"
app:constraint_referenced_ids="notificationAreaView,failedMessagesWarningStub" />
<im.vector.app.core.platform.BadgeFloatingActionButton
android:id="@+id/jumpToBottomView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
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"
app:badgeTextPadding="2dp"
app:badgeTextSize="10sp"
app:layout_constraintBottom_toTopOf="@id/bottomBarrier"
app:layout_constraintEnd_toEndOf="parent"
app:tint="@android:color/black" />
<im.vector.app.core.ui.views.CompatKonfetti
android:id="@+id/viewKonfetti"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="invisible" />
<com.jetradarmobile.snowfall.SnowfallView
android:id="@+id/viewSnowFall"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?vctr_chat_effect_snow_background"
android:visibility="invisible" />
<!-- Room not found layout -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/roomNotFound"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="?android:colorBackground"
android:elevation="10dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="gone">
<ImageView
android:id="@+id/roomNotFoundIcon"
android:layout_width="60dp"
android:layout_height="60dp"
android:importantForAccessibility="no"
android:src="@drawable/ic_alert_triangle"
app:layout_constraintBottom_toTopOf="@id/roomNotFoundText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed" />
<TextView
android:id="@+id/roomNotFoundText"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/layout_vertical_margin"
android:gravity="center"
android:paddingHorizontal="16dp"
android:text="@string/timeline_error_room_not_found"
android:textColor="?vctr_content_primary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomNotFoundIcon" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -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">
<TextView
android:id="@+id/liveIndicator"
android:layout_width="wrap_content"
android:layout_height="20dp"
style="@style/VoiceBroadcastLiveIndicator"
android:background="@drawable/rounded_rect_shape_2"
android:backgroundTint="?colorError"
android:drawablePadding="4dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxWidth="100dp"
android:paddingHorizontal="4dp"
android:singleLine="true"
android:text="@string/voice_broadcast_live"
android:textColor="?colorOnError"
app:drawableStartCompat="@drawable/ic_voice_broadcast_16"
app:drawableTint="?colorOnError"
app:drawableStartCompat="@drawable/ic_voice_broadcast"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@ -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" />
<LinearLayout
android:id="@+id/broadcasterViewGroup"
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/metadataFlow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:orientation="vertical"
app:constraint_referenced_ids="broadcasterNameMetadata,voiceBroadcastMetadata,listenersCountMetadata"
app:flow_horizontalAlign="start"
app:flow_verticalGap="4dp"
app:layout_constraintStart_toEndOf="@id/avatarRightBarrier"
app:layout_constraintTop_toBottomOf="@id/titleText">
app:layout_constraintTop_toBottomOf="@id/titleText" />
<ImageView
android:id="@+id/broadcasterIcon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginEnd="5dp"
android:contentDescription="@null"
android:src="@drawable/ic_microphone"
app:tint="?vctr_content_secondary" />
<TextView
android:id="@+id/broadcasterNameText"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="@sample/users.json/data/displayName" />
</LinearLayout>
<LinearLayout
android:id="@+id/voiceBroadcastViewGroup"
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/broadcasterNameMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center_vertical"
android:orientation="horizontal"
app:layout_constraintStart_toEndOf="@id/avatarRightBarrier"
app:layout_constraintTop_toBottomOf="@id/broadcasterViewGroup">
app:metadataIcon="@drawable/ic_voice_broadcast_mic"
tools:metadataValue="@sample/users.json/data/displayName" />
<ImageView
android:id="@+id/voiceBroadcastIcon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginEnd="5dp"
android:contentDescription="@null"
android:src="@drawable/ic_voice_broadcast_16"
app:tint="?vctr_content_secondary" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/voiceBroadcastMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:metadataIcon="@drawable/ic_voice_broadcast"
app:metadataValue="@string/attachment_type_voice_broadcast" />
<TextView
android:id="@+id/voiceBroadcastText"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/attachment_type_voice_broadcast" />
</LinearLayout>
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/listenersCountMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:metadataIcon="@drawable/ic_member_small"
app:metadataValue="@string/no_value_placeholder"
tools:metadataValue="5 listeners" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/headerBottomBarrier"
@ -116,7 +85,16 @@
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:barrierMargin="12dp"
app:constraint_referenced_ids="roomAvatarImageView,titleText,broadcasterViewGroup,voiceBroadcastViewGroup" />
app:constraint_referenced_ids="roomAvatarImageView,titleText,metadataFlow" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/controllerButtonsFlow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:constraint_referenced_ids="playPauseButton,bufferingView"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/headerBottomBarrier" />
<ImageButton
android:id="@+id/playPauseButton"
@ -126,24 +104,14 @@
android:backgroundTint="?vctr_system"
android:contentDescription="@string/a11y_play_voice_broadcast"
android:src="@drawable/ic_play_pause_play"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/headerBottomBarrier"
app:tint="?vctr_content_secondary" />
<ProgressBar
android:id="@+id/bufferingView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/a11y_voice_broadcast_buffering"
android:indeterminate="true"
android:indeterminateTint="?vctr_content_secondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/headerBottomBarrier" />
android:indeterminateTint="?vctr_content_secondary" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -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">
<TextView
android:id="@+id/liveIndicator"
android:layout_width="wrap_content"
android:layout_height="20dp"
style="@style/VoiceBroadcastLiveIndicator"
android:background="@drawable/rounded_rect_shape_2"
android:backgroundTint="?colorError"
android:drawablePadding="4dp"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxWidth="100dp"
android:paddingHorizontal="4dp"
android:singleLine="true"
android:text="@string/voice_broadcast_live"
android:textColor="?colorOnError"
app:drawableStartCompat="@drawable/ic_voice_broadcast_16"
app:drawableTint="?colorOnError"
app:drawableStartCompat="@drawable/ic_voice_broadcast"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@ -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" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/metadataFlow"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:orientation="vertical"
app:constraint_referenced_ids="listenersCountMetadata,remainingTimeMetadata"
app:flow_horizontalAlign="start"
app:flow_verticalGap="4dp"
app:layout_constraintStart_toEndOf="@id/avatarRightBarrier"
app:layout_constraintTop_toBottomOf="@id/titleText" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/listenersCountMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:metadataIcon="@drawable/ic_member_small"
app:metadataValue="@string/no_value_placeholder"
tools:metadataValue="5 listening" />
<im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView
android:id="@+id/remainingTimeMetadata"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:metadataIcon="@drawable/ic_timer"
tools:metadataValue="3h 2m 50s left" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/headerBottomBarrier"
@ -62,7 +78,16 @@
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:barrierMargin="12dp"
app:constraint_referenced_ids="roomAvatarImageView,titleText" />
app:constraint_referenced_ids="roomAvatarImageView,titleText,metadataFlow" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/controllerButtonsFlow"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
app:constraint_referenced_ids="recordButton,stopRecordButton"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintTop_toBottomOf="@id/headerBottomBarrier" />
<ImageButton
android:id="@+id/recordButton"
@ -71,11 +96,7 @@
android:background="@drawable/bg_rounded_button"
android:backgroundTint="?vctr_system"
android:contentDescription="@string/a11y_resume_voice_broadcast_record"
android:src="@drawable/ic_recording_dot"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/stopRecordButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/headerBottomBarrier" />
android:src="@drawable/ic_recording_dot" />
<ImageButton
android:id="@+id/stopRecordButton"
@ -84,10 +105,6 @@
android:background="@drawable/bg_rounded_button"
android:backgroundTint="?vctr_system"
android:contentDescription="@string/a11y_stop_voice_broadcast_record"
android:src="@drawable/ic_stop"
app:layout_constraintBottom_toBottomOf="@id/recordButton"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/recordButton"
app:layout_constraintTop_toTopOf="@id/recordButton" />
android:src="@drawable/ic_stop" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
tools:parentTag="android.widget.LinearLayout">
<ImageView
android:id="@+id/metadataIcon"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginEnd="4dp"
android:contentDescription="@null"
app:tint="?vctr_content_secondary"
tools:src="@drawable/ic_voice_broadcast" />
<TextView
android:id="@+id/metadata_value"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/no_value_placeholder"
tools:text="@string/attachment_type_voice_broadcast" />
</merge>

View file

@ -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,
)
}
}

View file

@ -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

View file

@ -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

View file

@ -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<VoiceBroadcastRecorder>(relaxed = true)
private val startVoiceBroadcastUseCase = StartVoiceBroadcastUseCase(
fakeSession,
fakeVoiceBroadcastRecorder,
FakeContext().instance,
mockk()
private val fakeGetOngoingVoiceBroadcastsUseCase = mockk<GetOngoingVoiceBroadcastsUseCase>()
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<VoiceBroadcast>) {
// Given
clearAllMocks()
givenAVoiceBroadcasts(voiceBroadcasts)
setup()
givenVoiceBroadcasts(voiceBroadcasts)
val voiceBroadcastInfoContentInterceptor = slot<Content>()
coEvery { fakeRoom.stateService().sendStateEvent(any(), any(), capture(voiceBroadcastInfoContentInterceptor)) } coAnswers { AN_EVENT_ID }
@ -102,8 +117,8 @@ class StartVoiceBroadcastUseCaseTest {
private suspend fun testVoiceBroadcastNotStarted(voiceBroadcasts: List<VoiceBroadcast>) {
// 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<VoiceBroadcast>) {
private fun givenVoiceBroadcasts(voiceBroadcasts: List<VoiceBroadcast>) {
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)

View file

@ -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

View file

@ -40,4 +40,7 @@ class FakeVectorPreferences {
fun givenIsClientInfoRecordingEnabled(isEnabled: Boolean) {
every { instance.isClientInfoRecordingEnabled() } returns isEnabled
}
fun givenTextFormatting(isEnabled: Boolean) =
every { instance.isTextFormattingEnabled() } returns isEnabled
}