Merge branch 'develop' into feature/bca/rust_flavor

This commit is contained in:
valere 2023-01-15 17:17:22 +01:00
commit 4ae93d5a2c
28 changed files with 333 additions and 107 deletions

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

@ -0,0 +1 @@
Fix can't get out of a verification dialog

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

@ -0,0 +1 @@
Handle exceptions when listening a voice broadcast

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

@ -0,0 +1 @@
[Voice Broadcast] Only display a notification on the first voice chunk

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

@ -0,0 +1 @@
Fix rendering of edited polls

View file

@ -50,7 +50,7 @@ ext.libs = [
], ],
androidx : [ androidx : [
'activity' : "androidx.activity:activity-ktx:1.6.1", 'activity' : "androidx.activity:activity-ktx:1.6.1",
'appCompat' : "androidx.appcompat:appcompat:1.5.1", 'appCompat' : "androidx.appcompat:appcompat:1.6.0",
'biometric' : "androidx.biometric:biometric:1.1.0", 'biometric' : "androidx.biometric:biometric:1.1.0",
'core' : "androidx.core:core-ktx:1.9.0", 'core' : "androidx.core:core-ktx:1.9.0",
'recyclerview' : "androidx.recyclerview:recyclerview:1.2.1", 'recyclerview' : "androidx.recyclerview:recyclerview:1.2.1",
@ -103,7 +103,7 @@ ext.libs = [
], ],
element : [ element : [
'opusencoder' : "io.element.android:opusencoder:1.1.0", 'opusencoder' : "io.element.android:opusencoder:1.1.0",
'wysiwyg' : "io.element.android:wysiwyg:0.15.0" 'wysiwyg' : "io.element.android:wysiwyg:0.17.0"
], ],
squareup : [ squareup : [
'moshi' : "com.squareup.moshi:moshi:$moshi", 'moshi' : "com.squareup.moshi:moshi:$moshi",

View file

@ -2860,7 +2860,7 @@
<string name="device_manager_sessions_sign_in_with_qr_code_title">Přihlásit se pomocí QR kódu</string> <string name="device_manager_sessions_sign_in_with_qr_code_title">Přihlásit se pomocí QR kódu</string>
<string name="login_scan_qr_code">Naskenovat QR kód</string> <string name="login_scan_qr_code">Naskenovat QR kód</string>
<string name="labs_enable_voice_broadcast_summary">Možnost nahrávat a odesílat hlasové vysílání na časové ose místnosti.</string> <string name="labs_enable_voice_broadcast_summary">Možnost nahrávat a odesílat hlasové vysílání na časové ose místnosti.</string>
<string name="labs_enable_voice_broadcast_title">Povolit hlasové vysílání (v aktivním vývoji)</string> <string name="labs_enable_voice_broadcast_title">Povolit hlasové vysílání</string>
<string name="qr_code_login_header_failed_homeserver_is_not_supported_description">Domovský server nepodporuje přihlášení pomocí QR kódu.</string> <string name="qr_code_login_header_failed_homeserver_is_not_supported_description">Domovský server nepodporuje přihlášení pomocí QR kódu.</string>
<string name="qr_code_login_header_failed_user_cancelled_description">Přihlášení bylo na druhém zařízení zrušeno.</string> <string name="qr_code_login_header_failed_user_cancelled_description">Přihlášení bylo na druhém zařízení zrušeno.</string>
<string name="qr_code_login_header_failed_invalid_qr_code_description">Tento QR kód je neplatný.</string> <string name="qr_code_login_header_failed_invalid_qr_code_description">Tento QR kód je neplatný.</string>
@ -2946,4 +2946,13 @@
<string name="set_link_link">Odkaz</string> <string name="set_link_link">Odkaz</string>
<string name="set_link_text">Text</string> <string name="set_link_text">Text</string>
<string name="rich_text_editor_link">Nastavit odkaz</string> <string name="rich_text_editor_link">Nastavit odkaz</string>
<string name="settings_access_token_summary">Přístupový token umožňuje plný přístup k účtu. Nikomu ho nesdělujte.</string>
<string name="settings_access_token">Přístupový token</string>
<string name="rich_text_editor_bullet_list">Přepnout na odrážky</string>
<string name="rich_text_editor_numbered_list">Přepnout na číslovaný seznam</string>
<string name="room_polls_ended_no_item">V této místnosti nejsou žádné předchozí hlasování</string>
<string name="room_polls_ended">Předchozí hlasování</string>
<string name="room_polls_active_no_item">V této místnosti nejsou žádné aktivní hlasování</string>
<string name="room_polls_active">Aktivní hlasování</string>
<string name="room_profile_section_more_polls">Historie hlasování</string>
</resources> </resources>

View file

@ -1123,9 +1123,7 @@
<string name="settings_select_country">Elektu landon</string> <string name="settings_select_country">Elektu landon</string>
<string name="settings_emails_and_phone_numbers_summary">Administri retpoŝtadresojn kaj telefonnumerojn ligitajn al via konto de Matrix</string> <string name="settings_emails_and_phone_numbers_summary">Administri retpoŝtadresojn kaj telefonnumerojn ligitajn al via konto de Matrix</string>
<string name="settings_emails_and_phone_numbers_title">Retpoŝtadresoj kaj telefonnumeroj</string> <string name="settings_emails_and_phone_numbers_title">Retpoŝtadresoj kaj telefonnumeroj</string>
<string name="settings_unignore_user">Ĉu montri ĉiujn mesaĝojn de %s\? <string name="settings_unignore_user">Ĉu montri ĉiujn mesaĝojn de %s\?</string>
\n
\nSciu ke tiu ĉi ago reekigos la aplikaĵon, kaj tio povas daŭri iom da tempo.</string>
<string name="settings_password_updated">Via pasvorto ĝisdatiĝis</string> <string name="settings_password_updated">Via pasvorto ĝisdatiĝis</string>
<string name="settings_fail_to_update_password_invalid_current_password">La pasvorto ne validas</string> <string name="settings_fail_to_update_password_invalid_current_password">La pasvorto ne validas</string>
<string name="settings_fail_to_update_password">Malsukcesis ĝisdatigi pasvorton</string> <string name="settings_fail_to_update_password">Malsukcesis ĝisdatigi pasvorton</string>
@ -2201,4 +2199,5 @@
<string name="call_ringing">Sonorante…</string> <string name="call_ringing">Sonorante…</string>
<string name="spaces">Aroj</string> <string name="spaces">Aroj</string>
<string name="initial_sync_request_reason_unignored_users">- Iom uzantoj reatentita</string> <string name="initial_sync_request_reason_unignored_users">- Iom uzantoj reatentita</string>
<string name="settings_mentions_at_room">\@room</string>
</resources> </resources>

View file

@ -2890,4 +2890,13 @@ A Visszaállítási Kulcsot tartsd biztonságos helyen, mint pl. egy jelszókeze
<string name="set_link_link">Hivatkozás</string> <string name="set_link_link">Hivatkozás</string>
<string name="set_link_text">Szöveg</string> <string name="set_link_text">Szöveg</string>
<string name="rich_text_editor_link">Hivatkozás beállítása</string> <string name="rich_text_editor_link">Hivatkozás beállítása</string>
<string name="settings_access_token_summary">A hozzáférési kulcs teljes elérést biztosít a fiókhoz. Soha ne ossza meg mással.</string>
<string name="settings_access_token">Elérési kulcs</string>
<string name="rich_text_editor_bullet_list">Lista ki-,bekapcsolása</string>
<string name="rich_text_editor_numbered_list">Számozott lista ki-,bekapcsolása</string>
<string name="room_polls_ended_no_item">Nincsenek régi szavazások ebben a szobában</string>
<string name="room_polls_ended">Régi szavazások</string>
<string name="room_polls_active_no_item">Nincsenek aktív szavazások ebben a szobában</string>
<string name="room_polls_active">Aktív szavazások</string>
<string name="room_profile_section_more_polls">Szavazás alakulása</string>
</resources> </resources>

View file

@ -2296,6 +2296,7 @@
<string name="sent_verification_conclusion">Verification Conclusion</string> <string name="sent_verification_conclusion">Verification Conclusion</string>
<string name="sent_location">Shared their location</string> <string name="sent_location">Shared their location</string>
<string name="sent_live_location">Shared their live location</string> <string name="sent_live_location">Shared their live location</string>
<string name="started_a_voice_broadcast">Started a voice broadcast</string>
<string name="verification_request_waiting">Waiting…</string> <string name="verification_request_waiting">Waiting…</string>
<string name="verification_request_other_cancelled">%s canceled</string> <string name="verification_request_other_cancelled">%s canceled</string>
@ -3132,6 +3133,7 @@
<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_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_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="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="error_voice_broadcast_unable_to_play">Unable to play this voice broadcast.</string>
<!-- Examples of usage: 6h 15min 30sec left / 15min 30sec left / 30sec left --> <!-- Examples of usage: 6h 15min 30sec left / 15min 30sec left / 30sec left -->
<string name="voice_broadcast_recording_time_left">%1$s left</string> <string name="voice_broadcast_recording_time_left">%1$s left</string>
<string name="stop_voice_broadcast_dialog_title">Stop live broadcasting?</string> <string name="stop_voice_broadcast_dialog_title">Stop live broadcasting?</string>

View file

@ -148,8 +148,8 @@ fun TimelineEvent.getLastMessageContent(): MessageContent? {
// Polls/Beacon are not message contents like others as there is no msgtype subtype to discriminate moshi parsing // Polls/Beacon are not message contents like others as there is no msgtype subtype to discriminate moshi parsing
// so toModel<MessageContent> won't parse them correctly // so toModel<MessageContent> won't parse them correctly
// It's discriminated on event type instead. Maybe it shouldn't be MessageContent at all to avoid confusion? // It's discriminated on event type instead. Maybe it shouldn't be MessageContent at all to avoid confusion?
in EventType.POLL_START.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessagePollContent>() in EventType.POLL_START.values -> (getLastPollEditNewContent() ?: root.getClearContent()).toModel<MessagePollContent>()
in EventType.POLL_END.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageEndPollContent>() in EventType.POLL_END.values -> (getLastPollEditNewContent() ?: root.getClearContent()).toModel<MessageEndPollContent>()
in EventType.STATE_ROOM_BEACON_INFO.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageBeaconInfoContent>() in EventType.STATE_ROOM_BEACON_INFO.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageBeaconInfoContent>()
in EventType.BEACON_LOCATION_DATA.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageBeaconLocationDataContent>() in EventType.BEACON_LOCATION_DATA.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel<MessageBeaconLocationDataContent>()
else -> (getLastEditNewContent() ?: root.getClearContent()).toModel() else -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
@ -160,6 +160,10 @@ fun TimelineEvent.getLastEditNewContent(): Content? {
return annotations?.editSummary?.latestEdit?.getClearContent()?.toModel<MessageContent>()?.newContent return annotations?.editSummary?.latestEdit?.getClearContent()?.toModel<MessageContent>()?.newContent
} }
private fun TimelineEvent.getLastPollEditNewContent(): Content? {
return annotations?.editSummary?.latestEdit?.getClearContent()?.toModel<MessagePollContent>()?.newContent
}
/** /**
* Returns true if it's a reply. * Returns true if it's a reply.
*/ */

View file

@ -16,13 +16,16 @@
package org.matrix.android.sdk.internal.session.room package org.matrix.android.sdk.internal.session.room
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.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.LocalEcho import org.matrix.android.sdk.api.session.events.model.LocalEcho
import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.RelationType
import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.getRelationContent
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent
import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@ -101,7 +104,7 @@ internal class EventEditValidator @Inject constructor(val cryptoStore: IMXCrypto
if (originalDecrypted.type != replaceDecrypted.type) { if (originalDecrypted.type != replaceDecrypted.type) {
return EditValidity.Invalid("replacement and original events must have the same type") return EditValidity.Invalid("replacement and original events must have the same type")
} }
if (replaceDecrypted.clearContent.toModel<MessageContent>()?.newContent == null) { if (!hasNewContent(replaceDecrypted.type, replaceDecrypted.clearContent)) {
return EditValidity.Invalid("replacement event must have an m.new_content property") return EditValidity.Invalid("replacement event must have an m.new_content property")
} }
} else { } else {
@ -116,11 +119,18 @@ internal class EventEditValidator @Inject constructor(val cryptoStore: IMXCrypto
if (originalEvent.type != replaceEvent.type) { if (originalEvent.type != replaceEvent.type) {
return EditValidity.Invalid("replacement and original events must have the same type") return EditValidity.Invalid("replacement and original events must have the same type")
} }
if (replaceEvent.content.toModel<MessageContent>()?.newContent == null) { if (!hasNewContent(replaceEvent.type, replaceEvent.content)) {
return EditValidity.Invalid("replacement event must have an m.new_content property") return EditValidity.Invalid("replacement event must have an m.new_content property")
} }
} }
return EditValidity.Valid return EditValidity.Valid
} }
private fun hasNewContent(eventType: String?, content: Content?): Boolean {
return when (eventType) {
in EventType.POLL_START.values -> content.toModel<MessagePollContent>()?.newContent != null
else -> content.toModel<MessageContent>()?.newContent != null
}
}
} }

View file

@ -157,6 +157,8 @@ class DefaultErrorFormatter @Inject constructor(
RecordingError.BlockedBySomeoneElse -> stringProvider.getString(R.string.error_voice_broadcast_blocked_by_someone_else_message) 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.NoPermission -> stringProvider.getString(R.string.error_voice_broadcast_permission_denied_message)
RecordingError.UserAlreadyBroadcasting -> stringProvider.getString(R.string.error_voice_broadcast_already_in_progress_message) RecordingError.UserAlreadyBroadcasting -> stringProvider.getString(R.string.error_voice_broadcast_already_in_progress_message)
is VoiceBroadcastFailure.ListeningError.UnableToPlay,
is VoiceBroadcastFailure.ListeningError.DownloadError -> stringProvider.getString(R.string.error_voice_broadcast_unable_to_play)
} }
} }

View file

@ -229,6 +229,7 @@ class VoiceMessageRecorderView @JvmOverloads constructor(
voiceMessageViews.renderPlaying(state) voiceMessageViews.renderPlaying(state)
} }
is AudioMessagePlaybackTracker.Listener.State.Paused, is AudioMessagePlaybackTracker.Listener.State.Paused,
is AudioMessagePlaybackTracker.Listener.State.Error,
is AudioMessagePlaybackTracker.Listener.State.Idle -> { is AudioMessagePlaybackTracker.Listener.State.Idle -> {
voiceMessageViews.renderIdle() voiceMessageViews.renderIdle()
} }

View file

@ -217,8 +217,8 @@ class MessageActionsViewModel @AssistedInject constructor(
noticeEventFormatter.format(timelineEvent, room?.roomSummary()?.isDirect.orFalse()) noticeEventFormatter.format(timelineEvent, room?.roomSummary()?.isDirect.orFalse())
} }
in EventType.POLL_START.values -> { in EventType.POLL_START.values -> {
timelineEvent.root.getClearContent().toModel<MessagePollContent>(catchError = true) (timelineEvent.getVectorLastMessageContent() as? MessagePollContent)?.getBestPollCreationInfo()?.question?.getBestQuestion()
?.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "" ?: stringProvider.getString(R.string.message_reply_to_poll_preview)
} }
else -> null else -> null
} }

View file

@ -15,9 +15,9 @@
*/ */
package im.vector.app.features.home.room.detail.timeline.factory package im.vector.app.features.home.room.detail.timeline.factory
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.DrawableProvider
import im.vector.app.features.displayname.getBestName
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider 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.helper.VoiceBroadcastEventsGroup
@ -36,7 +36,6 @@ import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.getUserOrDefault
import org.matrix.android.sdk.api.util.toMatrixItem import org.matrix.android.sdk.api.util.toMatrixItem
import javax.inject.Inject import javax.inject.Inject
@ -45,6 +44,7 @@ class VoiceBroadcastItemFactory @Inject constructor(
private val avatarSizeProvider: AvatarSizeProvider, private val avatarSizeProvider: AvatarSizeProvider,
private val colorProvider: ColorProvider, private val colorProvider: ColorProvider,
private val drawableProvider: DrawableProvider, private val drawableProvider: DrawableProvider,
private val errorFormatter: ErrorFormatter,
private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, private val voiceBroadcastRecorder: VoiceBroadcastRecorder?,
private val voiceBroadcastPlayer: VoiceBroadcastPlayer, private val voiceBroadcastPlayer: VoiceBroadcastPlayer,
private val playbackTracker: AudioMessagePlaybackTracker, private val playbackTracker: AudioMessagePlaybackTracker,
@ -75,13 +75,14 @@ class VoiceBroadcastItemFactory @Inject constructor(
voiceBroadcast = voiceBroadcast, voiceBroadcast = voiceBroadcast,
voiceBroadcastState = voiceBroadcastContent.voiceBroadcastState, voiceBroadcastState = voiceBroadcastContent.voiceBroadcastState,
duration = voiceBroadcastEventsGroup.getDuration(), duration = voiceBroadcastEventsGroup.getDuration(),
recorderName = params.event.root.stateKey?.let { session.getUserOrDefault(it) }?.toMatrixItem()?.getBestName().orEmpty(), recorderName = params.event.senderInfo.disambiguatedDisplayName,
recorder = voiceBroadcastRecorder, recorder = voiceBroadcastRecorder,
player = voiceBroadcastPlayer, player = voiceBroadcastPlayer,
playbackTracker = playbackTracker, playbackTracker = playbackTracker,
roomItem = session.getRoom(params.event.roomId)?.roomSummary()?.toMatrixItem(), roomItem = session.getRoom(params.event.roomId)?.roomSummary()?.toMatrixItem(),
colorProvider = colorProvider, colorProvider = colorProvider,
drawableProvider = drawableProvider, drawableProvider = drawableProvider,
errorFormatter = errorFormatter,
) )
return if (isRecording) { return if (isRecording) {

View file

@ -27,6 +27,7 @@ import im.vector.app.core.resources.StringProvider
import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.html.EventHtmlRenderer
import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants
import im.vector.app.features.voicebroadcast.isLive import im.vector.app.features.voicebroadcast.isLive
import im.vector.app.features.voicebroadcast.isVoiceBroadcast
import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent
import me.gujun.android.span.image import me.gujun.android.span.image
import me.gujun.android.span.span import me.gujun.android.span.span
@ -39,6 +40,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.getTextDisplayableContent import org.matrix.android.sdk.api.session.room.timeline.getTextDisplayableContent
@ -86,10 +88,16 @@ class DisplayableEventFormatter @Inject constructor(
simpleFormat(senderName, stringProvider.getString(R.string.sent_an_image), appendAuthor) simpleFormat(senderName, stringProvider.getString(R.string.sent_an_image), appendAuthor)
} }
MessageType.MSGTYPE_AUDIO -> { MessageType.MSGTYPE_AUDIO -> {
if ((messageContent as? MessageAudioContent)?.voiceMessageIndicator != null) { when {
simpleFormat(senderName, stringProvider.getString(R.string.sent_a_voice_message), appendAuthor) (messageContent as? MessageAudioContent)?.voiceMessageIndicator == null -> {
} else { simpleFormat(senderName, stringProvider.getString(R.string.sent_an_audio_file), appendAuthor)
simpleFormat(senderName, stringProvider.getString(R.string.sent_an_audio_file), appendAuthor) }
timelineEvent.root.asMessageAudioEvent().isVoiceBroadcast() -> {
simpleFormat(senderName, stringProvider.getString(R.string.started_a_voice_broadcast), appendAuthor)
}
else -> {
simpleFormat(senderName, stringProvider.getString(R.string.sent_a_voice_message), appendAuthor)
}
} }
} }
MessageType.MSGTYPE_VIDEO -> { MessageType.MSGTYPE_VIDEO -> {
@ -130,7 +138,7 @@ class DisplayableEventFormatter @Inject constructor(
span { } span { }
} }
in EventType.POLL_START.values -> { in EventType.POLL_START.values -> {
timelineEvent.root.getClearContent().toModel<MessagePollContent>(catchError = true)?.getBestPollCreationInfo()?.question?.getBestQuestion() (timelineEvent.getVectorLastMessageContent() as? MessagePollContent)?.getBestPollCreationInfo()?.question?.getBestQuestion()
?: stringProvider.getString(R.string.sent_a_poll) ?: stringProvider.getString(R.string.sent_a_poll)
} }
in EventType.POLL_RESPONSE.values -> { in EventType.POLL_RESPONSE.values -> {

View file

@ -50,8 +50,11 @@ class AudioMessagePlaybackTracker @Inject constructor() {
listeners.remove(id) listeners.remove(id)
} }
fun pauseAllPlaybacks() { fun unregisterListeners() {
listeners.keys.forEach(::pausePlayback) listeners.forEach {
it.value.onUpdate(Listener.State.Idle)
}
listeners.clear()
} }
/** /**
@ -84,6 +87,10 @@ class AudioMessagePlaybackTracker @Inject constructor() {
} }
} }
fun pauseAllPlaybacks() {
listeners.keys.forEach(::pausePlayback)
}
fun pausePlayback(id: String) { fun pausePlayback(id: String) {
val state = getPlaybackState(id) val state = getPlaybackState(id)
if (state is Listener.State.Playing) { if (state is Listener.State.Playing) {
@ -94,7 +101,14 @@ class AudioMessagePlaybackTracker @Inject constructor() {
} }
fun stopPlayback(id: String) { fun stopPlayback(id: String) {
setState(id, Listener.State.Idle) val state = getPlaybackState(id)
if (state !is Listener.State.Error) {
setState(id, Listener.State.Idle)
}
}
fun onError(id: String, error: Throwable) {
setState(id, Listener.State.Error(error))
} }
fun updatePlayingAtPlaybackTime(id: String, time: Int, percentage: Float) { fun updatePlayingAtPlaybackTime(id: String, time: Int, percentage: Float) {
@ -116,6 +130,7 @@ class AudioMessagePlaybackTracker @Inject constructor() {
is Listener.State.Playing -> state.playbackTime is Listener.State.Playing -> state.playbackTime
is Listener.State.Paused -> state.playbackTime is Listener.State.Paused -> state.playbackTime
is Listener.State.Recording, is Listener.State.Recording,
is Listener.State.Error,
Listener.State.Idle, Listener.State.Idle,
null -> null null -> null
} }
@ -126,18 +141,12 @@ class AudioMessagePlaybackTracker @Inject constructor() {
is Listener.State.Playing -> state.percentage is Listener.State.Playing -> state.percentage
is Listener.State.Paused -> state.percentage is Listener.State.Paused -> state.percentage
is Listener.State.Recording, is Listener.State.Recording,
is Listener.State.Error,
Listener.State.Idle, Listener.State.Idle,
null -> null null -> null
} }
} }
fun unregisterListeners() {
listeners.forEach {
it.value.onUpdate(Listener.State.Idle)
}
listeners.clear()
}
companion object { companion object {
const val RECORDING_ID = "RECORDING_ID" const val RECORDING_ID = "RECORDING_ID"
} }
@ -148,6 +157,7 @@ class AudioMessagePlaybackTracker @Inject constructor() {
sealed class State { sealed class State {
object Idle : State() object Idle : State()
data class Error(val failure: Throwable) : State()
data class Playing(val playbackTime: Int, val percentage: Float) : State() data class Playing(val playbackTime: Int, val percentage: Float) : State()
data class Paused(val playbackTime: Int, val percentage: Float) : State() data class Paused(val playbackTime: Int, val percentage: Float) : State()
data class Recording(val amplitudeList: List<Int>) : State() data class Recording(val amplitudeList: List<Int>) : State()

View file

@ -22,6 +22,7 @@ import androidx.annotation.IdRes
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.extensions.tintBackground import im.vector.app.core.extensions.tintBackground
import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.DrawableProvider
@ -48,6 +49,7 @@ abstract class AbsMessageVoiceBroadcastItem<H : AbsMessageVoiceBroadcastItem.Hol
protected val colorProvider get() = voiceBroadcastAttributes.colorProvider protected val colorProvider get() = voiceBroadcastAttributes.colorProvider
protected val drawableProvider get() = voiceBroadcastAttributes.drawableProvider protected val drawableProvider get() = voiceBroadcastAttributes.drawableProvider
protected val avatarRenderer get() = attributes.avatarRenderer protected val avatarRenderer get() = attributes.avatarRenderer
protected val errorFormatter get() = voiceBroadcastAttributes.errorFormatter
protected val callback get() = attributes.callback protected val callback get() = attributes.callback
override fun isCacheable(): Boolean = false override fun isCacheable(): Boolean = false
@ -107,5 +109,6 @@ abstract class AbsMessageVoiceBroadcastItem<H : AbsMessageVoiceBroadcastItem.Hol
val roomItem: MatrixItem?, val roomItem: MatrixItem?,
val colorProvider: ColorProvider, val colorProvider: ColorProvider,
val drawableProvider: DrawableProvider, val drawableProvider: DrawableProvider,
val errorFormatter: ErrorFormatter,
) )
} }

View file

@ -142,6 +142,7 @@ abstract class MessageAudioItem : AbsMessageItem<MessageAudioItem.Holder>() {
private fun renderStateBasedOnAudioPlayback(holder: Holder) { private fun renderStateBasedOnAudioPlayback(holder: Holder) {
audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state -> audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state ->
when (state) { when (state) {
is AudioMessagePlaybackTracker.Listener.State.Error,
is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder) is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder)
is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state) is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state)
is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state) is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state)

View file

@ -20,11 +20,13 @@ import android.text.format.DateUtils
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.TextView import android.widget.TextView
import androidx.constraintlayout.widget.Group
import androidx.core.view.isInvisible import androidx.core.view.isInvisible
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.epoxy.onClick import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer
@ -54,6 +56,16 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
} }
} }
player.addListener(voiceBroadcast, playerListener) player.addListener(voiceBroadcast, playerListener)
playbackTracker.track(voiceBroadcast.voiceBroadcastId) { playbackState ->
renderBackwardForwardButtons(holder, playbackState)
renderPlaybackError(holder, playbackState)
renderLiveIndicator(holder)
if (!isUserSeeking) {
holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0
}
}
bindSeekBar(holder) bindSeekBar(holder)
bindButtons(holder) bindButtons(holder)
} }
@ -63,10 +75,11 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
playPauseButton.setOnClickListener { playPauseButton.setOnClickListener {
if (player.currentVoiceBroadcast == voiceBroadcast) { if (player.currentVoiceBroadcast == voiceBroadcast) {
when (player.playingState) { when (player.playingState) {
VoiceBroadcastPlayer.State.PLAYING, VoiceBroadcastPlayer.State.Playing,
VoiceBroadcastPlayer.State.BUFFERING -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause) VoiceBroadcastPlayer.State.Buffering -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause)
VoiceBroadcastPlayer.State.PAUSED, VoiceBroadcastPlayer.State.Paused,
VoiceBroadcastPlayer.State.IDLE -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) is VoiceBroadcastPlayer.State.Error,
VoiceBroadcastPlayer.State.Idle -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast))
} }
} else { } else {
callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast))
@ -100,17 +113,18 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
private fun renderPlayingState(holder: Holder, state: VoiceBroadcastPlayer.State) { private fun renderPlayingState(holder: Holder, state: VoiceBroadcastPlayer.State) {
with(holder) { with(holder) {
bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING bufferingView.isVisible = state == VoiceBroadcastPlayer.State.Buffering
voiceBroadcastMetadata.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING voiceBroadcastMetadata.isVisible = state != VoiceBroadcastPlayer.State.Buffering
when (state) { when (state) {
VoiceBroadcastPlayer.State.PLAYING, VoiceBroadcastPlayer.State.Playing,
VoiceBroadcastPlayer.State.BUFFERING -> { VoiceBroadcastPlayer.State.Buffering -> {
playPauseButton.setImageResource(R.drawable.ic_play_pause_pause) playPauseButton.setImageResource(R.drawable.ic_play_pause_pause)
playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast)
} }
VoiceBroadcastPlayer.State.IDLE, is VoiceBroadcastPlayer.State.Error,
VoiceBroadcastPlayer.State.PAUSED -> { VoiceBroadcastPlayer.State.Idle,
VoiceBroadcastPlayer.State.Paused -> {
playPauseButton.setImageResource(R.drawable.ic_play_pause_play) playPauseButton.setImageResource(R.drawable.ic_play_pause_play)
playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast)
} }
@ -120,6 +134,18 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
} }
} }
private fun renderPlaybackError(holder: Holder, playbackState: State) {
with(holder) {
if (playbackState is State.Error) {
controlsGroup.isVisible = false
errorView.setTextOrHide(errorFormatter.toHumanReadable(playbackState.failure))
} else {
errorView.isVisible = false
controlsGroup.isVisible = true
}
}
}
private fun bindSeekBar(holder: Holder) { private fun bindSeekBar(holder: Holder) {
with(holder) { with(holder) {
remainingTimeView.text = formatRemainingTime(duration) remainingTimeView.text = formatRemainingTime(duration)
@ -141,13 +167,6 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
} }
}) })
} }
playbackTracker.track(voiceBroadcast.voiceBroadcastId) { playbackState ->
renderBackwardForwardButtons(holder, playbackState)
renderLiveIndicator(holder)
if (!isUserSeeking) {
holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0
}
}
} }
private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) { private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) {
@ -187,6 +206,8 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem
val broadcasterNameMetadata by bind<VoiceBroadcastMetadataView>(R.id.broadcasterNameMetadata) val broadcasterNameMetadata by bind<VoiceBroadcastMetadataView>(R.id.broadcasterNameMetadata)
val voiceBroadcastMetadata by bind<VoiceBroadcastMetadataView>(R.id.voiceBroadcastMetadata) val voiceBroadcastMetadata by bind<VoiceBroadcastMetadataView>(R.id.voiceBroadcastMetadata)
val listenersCountMetadata by bind<VoiceBroadcastMetadataView>(R.id.listenersCountMetadata) val listenersCountMetadata by bind<VoiceBroadcastMetadataView>(R.id.listenersCountMetadata)
val errorView by bind<TextView>(R.id.errorView)
val controlsGroup by bind<Group>(R.id.controlsGroup)
} }
companion object { companion object {

View file

@ -124,6 +124,7 @@ abstract class MessageVoiceItem : AbsMessageItem<MessageVoiceItem.Holder>() {
audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state -> audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state ->
when (state) { when (state) {
is AudioMessagePlaybackTracker.Listener.State.Error,
is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed) is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed) is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed)
is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed) is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed)

View file

@ -0,0 +1,59 @@
/*
* Copyright 2023 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.notifications
import im.vector.app.ActiveSessionDataSource
import im.vector.app.features.voicebroadcast.isVoiceBroadcast
import im.vector.app.features.voicebroadcast.sequence
import org.matrix.android.sdk.api.session.events.model.isVoiceMessage
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.getTimelineEvent
import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import javax.inject.Inject
class FilteredEventDetector @Inject constructor(
private val activeSessionDataSource: ActiveSessionDataSource
) {
/**
* Returns true if the given event should be ignored.
* Used to skip notifications if a non expected message is received.
*/
fun shouldBeIgnored(notifiableEvent: NotifiableEvent): Boolean {
val session = activeSessionDataSource.currentValue?.orNull() ?: return false
if (notifiableEvent is NotifiableMessageEvent) {
val room = session.getRoom(notifiableEvent.roomId) ?: return false
val timelineEvent = room.getTimelineEvent(notifiableEvent.eventId) ?: return false
return timelineEvent.shouldBeIgnored()
}
return false
}
/**
* Whether the timeline event should be ignored.
*/
private fun TimelineEvent.shouldBeIgnored(): Boolean {
if (root.isVoiceMessage()) {
val audioEvent = root.asMessageAudioEvent()
// if the event is a voice message related to a voice broadcast, only show the event on the first chunk.
return audioEvent.isVoiceBroadcast() && audioEvent?.sequence != 1
}
return false
}
}

View file

@ -47,6 +47,7 @@ class NotificationDrawerManager @Inject constructor(
private val notifiableEventProcessor: NotifiableEventProcessor, private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationRenderer: NotificationRenderer, private val notificationRenderer: NotificationRenderer,
private val notificationEventPersistence: NotificationEventPersistence, private val notificationEventPersistence: NotificationEventPersistence,
private val filteredEventDetector: FilteredEventDetector,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) { ) {
@ -100,6 +101,11 @@ class NotificationDrawerManager @Inject constructor(
Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}") Timber.d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}")
} }
if (filteredEventDetector.shouldBeIgnored(notifiableEvent)) {
Timber.d("onNotifiableEventReceived(): ignore the event")
return
}
add(notifiableEvent) add(notifiableEvent)
} }

View file

@ -16,10 +16,21 @@
package im.vector.app.features.voicebroadcast package im.vector.app.features.voicebroadcast
import android.media.MediaPlayer
sealed class VoiceBroadcastFailure : Throwable() { sealed class VoiceBroadcastFailure : Throwable() {
sealed class RecordingError : VoiceBroadcastFailure() { sealed class RecordingError : VoiceBroadcastFailure() {
object NoPermission : RecordingError() object NoPermission : RecordingError()
object BlockedBySomeoneElse : RecordingError() object BlockedBySomeoneElse : RecordingError()
object UserAlreadyBroadcasting : RecordingError() object UserAlreadyBroadcasting : RecordingError()
} }
sealed class ListeningError : VoiceBroadcastFailure() {
/**
* @property what the type of error that has occurred, see [MediaPlayer.OnErrorListener.onError].
* @property extra an extra code, specific to the error, see [MediaPlayer.OnErrorListener.onError].
*/
data class UnableToPlay(val what: Int, val extra: Int) : ListeningError()
data class DownloadError(override val cause: Throwable?) : ListeningError()
}
} }

View file

@ -16,6 +16,7 @@
package im.vector.app.features.voicebroadcast.listening package im.vector.app.features.voicebroadcast.listening
import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure
import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcast
interface VoiceBroadcastPlayer { interface VoiceBroadcastPlayer {
@ -26,7 +27,7 @@ interface VoiceBroadcastPlayer {
val currentVoiceBroadcast: VoiceBroadcast? val currentVoiceBroadcast: VoiceBroadcast?
/** /**
* The current playing [State], [State.IDLE] by default. * The current playing [State], [State.Idle] by default.
*/ */
val playingState: State val playingState: State
@ -68,11 +69,12 @@ interface VoiceBroadcastPlayer {
/** /**
* Player states. * Player states.
*/ */
enum class State { sealed interface State {
PLAYING, object Playing : State
PAUSED, object Paused : State
BUFFERING, object Buffering : State
IDLE data class Error(val failure: VoiceBroadcastFailure.ListeningError) : State
object Idle : State
} }
/** /**

View file

@ -24,7 +24,7 @@ import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.extensions.onFirst import im.vector.app.core.extensions.onFirst
import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker
import im.vector.app.features.session.coroutineScope import im.vector.app.features.session.coroutineScope
import im.vector.app.features.voice.VoiceFailure import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure
import im.vector.app.features.voicebroadcast.isLive import im.vector.app.features.voicebroadcast.isLive
import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.Listener 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.VoiceBroadcastPlayer.State
@ -79,7 +79,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
} }
} }
override var playingState = State.IDLE override var playingState: State = State.Idle
@MainThread @MainThread
set(value) { set(value) {
if (field != value) { if (field != value) {
@ -96,7 +96,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
val hasChanged = currentVoiceBroadcast != voiceBroadcast val hasChanged = currentVoiceBroadcast != voiceBroadcast
when { when {
hasChanged -> startPlayback(voiceBroadcast) hasChanged -> startPlayback(voiceBroadcast)
playingState == State.PAUSED -> resumePlayback() playingState == State.Paused -> resumePlayback()
else -> Unit else -> Unit
} }
} }
@ -107,7 +107,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
override fun stop() { override fun stop() {
// Update state // Update state
playingState = State.IDLE playingState = State.Idle
// Stop and release media players // Stop and release media players
stopPlayer() stopPlayer()
@ -129,7 +129,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
listeners[voiceBroadcast.voiceBroadcastId]?.add(listener) ?: run { listeners[voiceBroadcast.voiceBroadcastId]?.add(listener) ?: run {
listeners[voiceBroadcast.voiceBroadcastId] = CopyOnWriteArrayList<Listener>().apply { add(listener) } listeners[voiceBroadcast.voiceBroadcastId] = CopyOnWriteArrayList<Listener>().apply { add(listener) }
} }
listener.onPlayingStateChanged(if (voiceBroadcast == currentVoiceBroadcast) playingState else State.IDLE) listener.onPlayingStateChanged(if (voiceBroadcast == currentVoiceBroadcast) playingState else State.Idle)
listener.onLiveModeChanged(voiceBroadcast == currentVoiceBroadcast) listener.onLiveModeChanged(voiceBroadcast == currentVoiceBroadcast)
} }
@ -139,11 +139,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun startPlayback(voiceBroadcast: VoiceBroadcast) { private fun startPlayback(voiceBroadcast: VoiceBroadcast) {
// Stop listening previous voice broadcast if any // Stop listening previous voice broadcast if any
if (playingState != State.IDLE) stop() if (playingState != State.Idle) stop()
currentVoiceBroadcast = voiceBroadcast currentVoiceBroadcast = voiceBroadcast
playingState = State.BUFFERING playingState = State.Buffering
observeVoiceBroadcastStateEvent(voiceBroadcast) observeVoiceBroadcastStateEvent(voiceBroadcast)
} }
@ -175,13 +175,13 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun onPlaylistUpdated() { private fun onPlaylistUpdated() {
when (playingState) { when (playingState) {
State.PLAYING, State.Playing,
State.PAUSED -> { State.Paused -> {
if (nextMediaPlayer == null && !isPreparingNextPlayer) { if (nextMediaPlayer == null && !isPreparingNextPlayer) {
prepareNextMediaPlayer() prepareNextMediaPlayer()
} }
} }
State.BUFFERING -> { State.Buffering -> {
val nextItem = if (isLiveListening && playlist.currentSequence == null) { val nextItem = if (isLiveListening && playlist.currentSequence == null) {
// live listening, jump to the last item if playback has not started // live listening, jump to the last item if playback has not started
playlist.lastOrNull() playlist.lastOrNull()
@ -193,7 +193,8 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
startPlayback(nextItem.startTime) startPlayback(nextItem.startTime)
} }
} }
State.IDLE -> Unit // Should not happen is State.Error -> Unit
State.Idle -> Unit // Should not happen
} }
} }
@ -213,18 +214,17 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
if (sequencePosition > 0) { if (sequencePosition > 0) {
mp.seekTo(sequencePosition) mp.seekTo(sequencePosition)
} }
playingState = State.PLAYING playingState = State.Playing
prepareNextMediaPlayer() prepareNextMediaPlayer()
} }
} catch (failure: Throwable) { } catch (failure: VoiceBroadcastFailure.ListeningError.DownloadError) {
Timber.e(failure, "## Voice Broadcast | Unable to start playback: $failure") playingState = State.Error(failure)
throw VoiceFailure.UnableToPlay(failure)
} }
} }
} }
private fun pausePlayback() { private fun pausePlayback() {
playingState = State.PAUSED // This will trigger a playing state update and save the current position playingState = State.Paused // This will trigger a playing state update and save the current position
if (currentMediaPlayer != null) { if (currentMediaPlayer != null) {
currentMediaPlayer?.pause() currentMediaPlayer?.pause()
} else { } else {
@ -234,7 +234,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun resumePlayback() { private fun resumePlayback() {
if (currentMediaPlayer != null) { if (currentMediaPlayer != null) {
playingState = State.PLAYING playingState = State.Playing
currentMediaPlayer?.start() currentMediaPlayer?.start()
} else { } else {
val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } ?: 0 val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } ?: 0
@ -247,11 +247,11 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
voiceBroadcast != currentVoiceBroadcast -> { voiceBroadcast != currentVoiceBroadcast -> {
playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration)
} }
playingState == State.PLAYING || playingState == State.BUFFERING -> { playingState == State.Playing || playingState == State.Buffering -> {
updateLiveListeningMode(positionMillis) updateLiveListeningMode(positionMillis)
startPlayback(positionMillis) startPlayback(positionMillis)
} }
playingState == State.IDLE || playingState == State.PAUSED -> { playingState == State.Idle || playingState == State.Paused -> {
stopPlayer() stopPlayer()
playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration)
} }
@ -263,19 +263,29 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
if (nextItem != null) { if (nextItem != null) {
isPreparingNextPlayer = true isPreparingNextPlayer = true
sessionScope.launch { sessionScope.launch {
prepareMediaPlayer(nextItem.audioEvent.content) { mp -> try {
prepareMediaPlayer(nextItem.audioEvent.content) { mp ->
isPreparingNextPlayer = false
nextMediaPlayer = mp
when (playingState) {
State.Playing,
State.Paused -> {
currentMediaPlayer?.setNextMediaPlayer(mp)
}
State.Buffering -> {
mp.start()
onNextMediaPlayerStarted(mp)
}
is State.Error,
State.Idle -> stopPlayer()
}
}
} catch (failure: VoiceBroadcastFailure.ListeningError.DownloadError) {
isPreparingNextPlayer = false isPreparingNextPlayer = false
nextMediaPlayer = mp // Do not change the playingState if the current player is still valid,
when (playingState) { // the error will be thrown again when switching to the next player
State.PLAYING, if (playingState == State.Buffering || tryOrNull { currentMediaPlayer?.isPlaying } != true) {
State.PAUSED -> { playingState = State.Error(failure)
currentMediaPlayer?.setNextMediaPlayer(mp)
}
State.BUFFERING -> {
mp.start()
onNextMediaPlayerStarted(mp)
}
State.IDLE -> stopPlayer()
} }
} }
} }
@ -288,11 +298,12 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
session.fileService().downloadFile(messageAudioContent) session.fileService().downloadFile(messageAudioContent)
} catch (failure: Throwable) { } catch (failure: Throwable) {
Timber.e(failure, "Voice Broadcast | Download has failed: $failure") Timber.e(failure, "Voice Broadcast | Download has failed: $failure")
throw VoiceFailure.UnableToPlay(failure) throw VoiceBroadcastFailure.ListeningError.DownloadError(failure)
} }
return audioFile.inputStream().use { fis -> return audioFile.inputStream().use { fis ->
MediaPlayer().apply { MediaPlayer().apply {
setOnErrorListener(mediaPlayerListener)
setAudioAttributes( setAudioAttributes(
AudioAttributes.Builder() AudioAttributes.Builder()
// Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here // Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here
@ -302,10 +313,9 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
) )
setDataSource(fis.fd) setDataSource(fis.fd)
setOnInfoListener(mediaPlayerListener) setOnInfoListener(mediaPlayerListener)
setOnErrorListener(mediaPlayerListener)
setOnPreparedListener(onPreparedListener) setOnPreparedListener(onPreparedListener)
setOnCompletionListener(mediaPlayerListener) setOnCompletionListener(mediaPlayerListener)
prepare() prepareAsync()
} }
} }
} }
@ -327,11 +337,18 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
currentVoiceBroadcast?.voiceBroadcastId?.let { voiceBroadcastId -> currentVoiceBroadcast?.voiceBroadcastId?.let { voiceBroadcastId ->
// Start or stop playback ticker // Start or stop playback ticker
when (playingState) { when (playingState) {
State.PLAYING -> playbackTicker.startPlaybackTicker(voiceBroadcastId) State.Playing -> playbackTicker.startPlaybackTicker(voiceBroadcastId)
State.PAUSED, State.Paused,
State.BUFFERING, State.Buffering,
State.IDLE -> playbackTicker.stopPlaybackTicker(voiceBroadcastId) is State.Error,
State.Idle -> playbackTicker.stopPlaybackTicker(voiceBroadcastId)
} }
// Notify playback tracker about error
if (playingState is State.Error) {
playbackTracker.onError(voiceBroadcastId, playingState.failure)
}
// Notify state change to all the listeners attached to the current voice broadcast id // Notify state change to all the listeners attached to the current voice broadcast id
listeners[voiceBroadcastId]?.forEach { listener -> listener.onPlayingStateChanged(playingState) } listeners[voiceBroadcastId]?.forEach { listener -> listener.onPlayingStateChanged(playingState) }
} }
@ -348,7 +365,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
// the current voice broadcast is not live (ended) // the current voice broadcast is not live (ended)
mostRecentVoiceBroadcastEvent?.isLive != true -> false mostRecentVoiceBroadcastEvent?.isLive != true -> false
// the player is stopped or paused // the player is stopped or paused
playingState == State.IDLE || playingState == State.PAUSED -> false playingState == State.Idle || playingState == State.Paused -> false
seekPosition != null -> { seekPosition != null -> {
val seekDirection = seekPosition.compareTo(getCurrentPlaybackPosition() ?: 0) val seekDirection = seekPosition.compareTo(getCurrentPlaybackPosition() ?: 0)
val newSequence = playlist.findByPosition(seekPosition)?.sequence val newSequence = playlist.findByPosition(seekPosition)?.sequence
@ -374,13 +391,14 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun onLiveListeningChanged(isLiveListening: Boolean) { private fun onLiveListeningChanged(isLiveListening: Boolean) {
// Live has ended and last chunk has been reached, we can stop the playback // Live has ended and last chunk has been reached, we can stop the playback
if (!isLiveListening && playingState == State.BUFFERING && playlist.currentSequence == mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence) { val hasReachedLastChunk = playlist.currentSequence == mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence
if (!isLiveListening && playingState == State.Buffering && hasReachedLastChunk) {
stop() stop()
} }
} }
private fun onNextMediaPlayerStarted(mp: MediaPlayer) { private fun onNextMediaPlayerStarted(mp: MediaPlayer) {
playingState = State.PLAYING playingState = State.Playing
playlist.currentSequence = playlist.currentSequence?.inc() playlist.currentSequence = playlist.currentSequence?.inc()
currentMediaPlayer = mp currentMediaPlayer = mp
nextMediaPlayer = null nextMediaPlayer = null
@ -389,16 +407,16 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
private fun getCurrentPlaybackPosition(): Int? { private fun getCurrentPlaybackPosition(): Int? {
val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId ?: return null val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId ?: return null
val computedPosition = currentMediaPlayer?.currentPosition?.let { playlist.currentItem?.startTime?.plus(it) } val computedPosition = tryOrNull { currentMediaPlayer?.currentPosition }?.let { playlist.currentItem?.startTime?.plus(it) }
val savedPosition = playbackTracker.getPlaybackTime(voiceBroadcastId) val savedPosition = playbackTracker.getPlaybackTime(voiceBroadcastId)
return computedPosition ?: savedPosition return computedPosition ?: savedPosition
} }
private fun getCurrentPlaybackPercentage(): Float? { private fun getCurrentPlaybackPercentage(): Float? {
val playlistPosition = playlist.currentItem?.startTime val playlistPosition = playlist.currentItem?.startTime
val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition val computedPosition = tryOrNull { currentMediaPlayer?.currentPosition }?.let { playlistPosition?.plus(it) } ?: playlistPosition
val duration = playlist.duration.takeIf { it > 0 } val duration = playlist.duration
val computedPercentage = if (computedPosition != null && duration != null) computedPosition.toFloat() / duration else null val computedPercentage = if (computedPosition != null && duration > 0) computedPosition.toFloat() / duration else null
val savedPercentage = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPercentage(it) } val savedPercentage = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPercentage(it) }
return computedPercentage ?: savedPercentage return computedPercentage ?: savedPercentage
} }
@ -416,6 +434,14 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
} }
override fun onCompletion(mp: MediaPlayer) { override fun onCompletion(mp: MediaPlayer) {
// Release media player as soon as it completed
mp.release()
if (currentMediaPlayer == mp) {
currentMediaPlayer = null
} else {
error("The media player which has completed mismatches the current media player instance.")
}
// Next media player is already attached to this player and will start playing automatically // Next media player is already attached to this player and will start playing automatically
if (nextMediaPlayer != null) return if (nextMediaPlayer != null) return
@ -426,15 +452,18 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
// We'll not receive new chunks anymore so we can stop the live listening // We'll not receive new chunks anymore so we can stop the live listening
stop() stop()
} else { } else {
// Enter in buffering mode and release current media player playingState = State.Buffering
playingState = State.BUFFERING prepareNextMediaPlayer()
currentMediaPlayer?.release()
currentMediaPlayer = null
} }
} }
override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean {
stop() Timber.d("## Voice Broadcast | onError: what=$what, extra=$extra")
// Do not change the playingState if the current player is still valid,
// the error will be thrown again when switching to the next player
if (playingState == State.Buffering || tryOrNull { currentMediaPlayer?.isPlaying } != true) {
playingState = State.Error(VoiceBroadcastFailure.ListeningError.UnableToPlay(what, extra))
}
return true return true
} }
} }
@ -462,24 +491,25 @@ class VoiceBroadcastPlayerImpl @Inject constructor(
val playbackTime = getCurrentPlaybackPosition() val playbackTime = getCurrentPlaybackPosition()
val percentage = getCurrentPlaybackPercentage() val percentage = getCurrentPlaybackPercentage()
when (playingState) { when (playingState) {
State.PLAYING -> { State.Playing -> {
if (playbackTime != null && percentage != null) { if (playbackTime != null && percentage != null) {
playbackTracker.updatePlayingAtPlaybackTime(id, playbackTime, percentage) playbackTracker.updatePlayingAtPlaybackTime(id, playbackTime, percentage)
} }
} }
State.PAUSED, State.Paused,
State.BUFFERING -> { State.Buffering -> {
if (playbackTime != null && percentage != null) { if (playbackTime != null && percentage != null) {
playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage)
} }
} }
State.IDLE -> { State.Idle -> {
if (playbackTime == null || percentage == null || (playlist.duration - playbackTime) < 50) { if (playbackTime == null || percentage == null || (playlist.duration - playbackTime) < 50) {
playbackTracker.stopPlayback(id) playbackTracker.stopPlayback(id)
} else { } else {
playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage)
} }
} }
is State.Error -> Unit
} }
} }
} }

View file

@ -0,0 +1,10 @@
<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="M8,16C12.418,16 16,12.418 16,8C16,3.582 12.418,0 8,0C3.582,0 0,3.582 0,8C0,12.418 3.582,16 8,16ZM6.777,4.135C6.717,3.451 7.221,2.851 7.905,2.803C8.577,2.755 9.177,3.259 9.249,3.943V4.135L8.865,8.935C8.829,9.379 8.457,9.715 8.013,9.715H7.941C7.521,9.679 7.197,9.355 7.161,8.935L6.777,4.135ZM9.056,12.067C9.056,12.651 8.583,13.123 8,13.123C7.417,13.123 6.944,12.651 6.944,12.067C6.944,11.484 7.417,11.011 8,11.011C8.583,11.011 9.056,11.484 9.056,12.067Z"
android:fillColor="#FF5B55"
android:fillType="evenOdd" />
</vector>

View file

@ -176,4 +176,27 @@
tools:ignore="NegativeMargin" tools:ignore="NegativeMargin"
tools:text="-0:12" /> tools:text="-0:12" />
<androidx.constraintlayout.widget.Group
android:id="@+id/controlsGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:constraint_referenced_ids="controllerButtonsFlow,seekBar,elapsedTime,remainingTime" />
<TextView
android:id="@+id/errorView"
style="@style/Widget.Vector.TextView.Caption"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:drawablePadding="4dp"
android:text="@string/error_voice_broadcast_unable_to_play"
android:textColor="?colorError"
android:visibility="gone"
app:drawableStartCompat="@drawable/ic_voice_broadcast_error"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/headerBottomBarrier"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>