Browse Source

Receive and play a voice message (#1503)

## Type of change

- [x] Feature
- [ ] Bugfix
- [ ] Technical
- [ ] Other :

## Content

This PR consists of several macro-blocks separated by path/package:
- `messages.impl.mediaplayer` : Global (room-wide) media player, now used only for voice messages but could be used for all media within EX in the future. It is backed by media3's exoplayer. Currently not unit-tested because mocking exoplayer is not trivial.
- `messages.impl.voicemessages.play` : Business logic of a timeline voice message. This is all the logic that manages the voice message bubble.
- `messages.impl.timeline.model` & `messages.impl.timeline.factories`: Timeline code that takes care of creating the `content` object for voice messages.
-  `messages.impl.timeline.components` : The actual View composable that shows the UI inside a voice message bubble.

All the rest is just small related changes that must be done here and there in existing code.

From a high level perspective this is how it works:
- Voice messages are unlike other message bubbles because they carry state (i.e. playing, downloading...) so they have a Presenter managing this state.
- Media content (i.e. the ogg file) of a voice message is downloaded from the rust SDK on first play then stored in a voice messages cache (see the `VoiceMessageCache` class, it is just a subdirectory in the app's cacheDir which is indexed by the matrix content uri). All further play attempts are done from the cache without hitting the rust SDK anymore.
- Playback of the ogg file is handled with the `VoiceMessagePlayer` class which is basically a "view" of the global `MediaPlayer` that allow the voice message to only see the media player state belonging to its media content. 
- Drawing of the waveform is done with an OSS library wrapped in the `WaveformProgressIndicator` composable.

Known issues:
 - The waveform has no position slider.
 - The waveform (and together with it the whole message bubble) is taller than the actual Figma design.
 - Swipe to reply for voice messages is disabled to avoid conflict with the audio scrubbing gesture (to reply to a voice message you have to use the long press menu).
 - The loading indicator is always shown (there is no delay).
 - Voice messages don't stop playing when redacted.

## Motivation and context

https://github.com/vector-im/element-meta/issues/2083

## Screenshots / GIFs

Provided by Screenshot tests in the PR itself.
pull/1637/head
Marco Romano 11 months ago committed by GitHub
parent
commit
6e66c989f4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      changelog.d/2084.feature
  2. 2
      features/messages/impl/build.gradle.kts
  3. 7
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
  4. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
  5. 18
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
  6. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
  7. 192
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt
  8. 92
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/SimplePlayer.kt
  9. 15
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt
  10. 238
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt
  11. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt
  12. 35
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
  13. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt
  14. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
  15. 33
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt
  16. 58
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt
  17. 2
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt
  18. 123
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageCache.kt
  19. 22
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt
  20. 162
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt
  21. 151
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt
  22. 32
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt
  23. 55
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt
  24. 96
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/WaveformProgressIndicator.kt
  25. 9
      features/messages/impl/src/main/res/drawable/pause.xml
  26. 9
      features/messages/impl/src/main/res/drawable/play.xml
  27. 9
      features/messages/impl/src/main/res/drawable/retry.xml
  28. 28
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
  29. 6
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt
  30. 71
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/mediaplayer/FakeMediaPlayer.kt
  31. 49
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/FakeVoiceMessageCache.kt
  32. 90
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessageCacheTest.kt
  33. 294
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt
  34. 1
      gradle/libs.versions.toml
  35. 1
      settings.gradle.kts
  36. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceView-D-39_39_null_0,NEXUS_5,1.0,en].png
  37. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceView-D-39_39_null_1,NEXUS_5,1.0,en].png
  38. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceView-D-39_39_null_2,NEXUS_5,1.0,en].png
  39. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceView-N-39_40_null_0,NEXUS_5,1.0,en].png
  40. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceView-N-39_40_null_1,NEXUS_5,1.0,en].png
  41. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceView-N-39_40_null_2,NEXUS_5,1.0,en].png
  42. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceViewUnified-D-40_40_null,NEXUS_5,1.0,en].png
  43. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceViewUnified-N-40_41_null,NEXUS_5,1.0,en].png
  44. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.group_null_GroupHeaderView-D-41_41_null,NEXUS_5,1.0,en].png
  45. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.group_null_GroupHeaderView-N-41_42_null,NEXUS_5,1.0,en].png
  46. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_0,NEXUS_5,1.0,en].png
  47. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_1,NEXUS_5,1.0,en].png
  48. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_10,NEXUS_5,1.0,en].png
  49. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_11,NEXUS_5,1.0,en].png
  50. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_12,NEXUS_5,1.0,en].png
  51. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_13,NEXUS_5,1.0,en].png
  52. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_14,NEXUS_5,1.0,en].png
  53. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_15,NEXUS_5,1.0,en].png
  54. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_16,NEXUS_5,1.0,en].png
  55. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_17,NEXUS_5,1.0,en].png
  56. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_18,NEXUS_5,1.0,en].png
  57. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_19,NEXUS_5,1.0,en].png
  58. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_2,NEXUS_5,1.0,en].png
  59. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_20,NEXUS_5,1.0,en].png
  60. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_21,NEXUS_5,1.0,en].png
  61. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_3,NEXUS_5,1.0,en].png
  62. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_4,NEXUS_5,1.0,en].png
  63. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_5,NEXUS_5,1.0,en].png
  64. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_6,NEXUS_5,1.0,en].png
  65. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_7,NEXUS_5,1.0,en].png
  66. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_8,NEXUS_5,1.0,en].png
  67. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_9,NEXUS_5,1.0,en].png
  68. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_0,NEXUS_5,1.0,en].png
  69. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_1,NEXUS_5,1.0,en].png
  70. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_10,NEXUS_5,1.0,en].png
  71. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_11,NEXUS_5,1.0,en].png
  72. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_12,NEXUS_5,1.0,en].png
  73. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_13,NEXUS_5,1.0,en].png
  74. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_14,NEXUS_5,1.0,en].png
  75. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_15,NEXUS_5,1.0,en].png
  76. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_16,NEXUS_5,1.0,en].png
  77. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_17,NEXUS_5,1.0,en].png
  78. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_18,NEXUS_5,1.0,en].png
  79. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_19,NEXUS_5,1.0,en].png
  80. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_2,NEXUS_5,1.0,en].png
  81. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_20,NEXUS_5,1.0,en].png
  82. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_21,NEXUS_5,1.0,en].png
  83. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_3,NEXUS_5,1.0,en].png
  84. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_4,NEXUS_5,1.0,en].png
  85. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_5,NEXUS_5,1.0,en].png
  86. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_6,NEXUS_5,1.0,en].png
  87. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_7,NEXUS_5,1.0,en].png
  88. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_8,NEXUS_5,1.0,en].png
  89. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_9,NEXUS_5,1.0,en].png
  90. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.reactionsummary_null_SheetContent-D-43_43_null_0,NEXUS_5,1.0,en].png
  91. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.reactionsummary_null_SheetContent-N-43_44_null_0,NEXUS_5,1.0,en].png
  92. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.retrysendmenu_null_RetrySendMessageMenu-D-44_44_null_0,NEXUS_5,1.0,en].png
  93. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.retrysendmenu_null_RetrySendMessageMenu-D-44_44_null_1,NEXUS_5,1.0,en].png
  94. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.retrysendmenu_null_RetrySendMessageMenu-N-44_45_null_0,NEXUS_5,1.0,en].png
  95. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.retrysendmenu_null_RetrySendMessageMenu-N-44_45_null_1,NEXUS_5,1.0,en].png
  96. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineEncryptedHistoryBannerView-D-45_45_null,NEXUS_5,1.0,en].png
  97. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineEncryptedHistoryBannerView-N-45_46_null,NEXUS_5,1.0,en].png
  98. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineItemDaySeparatorView-D-46_46_null_0,NEXUS_5,1.0,en].png
  99. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineItemDaySeparatorView-D-46_46_null_1,NEXUS_5,1.0,en].png
  100. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineItemDaySeparatorView-N-46_47_null_0,NEXUS_5,1.0,en].png
  101. Some files were not shown because too many files have changed in this diff Show More

1
changelog.d/2084.feature

@ -0,0 +1 @@ @@ -0,0 +1 @@
Receive and play a voice message

2
features/messages/impl/build.gradle.kts

@ -52,6 +52,7 @@ dependencies { @@ -52,6 +52,7 @@ dependencies {
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.voicerecorder.api)
implementation(projects.libraries.uiUtils)
implementation(projects.features.networkmonitor.api)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)
@ -64,6 +65,7 @@ dependencies { @@ -64,6 +65,7 @@ dependencies {
implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.zoomableimage)
implementation(libs.matrix.emojibase.bindings)
implementation(libs.audiowaveform)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)

7
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt

@ -28,9 +28,10 @@ import dagger.assisted.Assisted @@ -28,9 +28,10 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@ -49,6 +50,7 @@ class MessagesNode @AssistedInject constructor( @@ -49,6 +50,7 @@ class MessagesNode @AssistedInject constructor(
private val analyticsService: AnalyticsService,
private val presenterFactory: MessagesPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
@ -71,6 +73,9 @@ class MessagesNode @AssistedInject constructor( @@ -71,6 +73,9 @@ class MessagesNode @AssistedInject constructor(
lifecycle.subscribe(
onCreate = {
analyticsService.capture(room.toAnalyticsViewRoom())
},
onDestroy = {
mediaPlayer.close()
}
)
}

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt

@ -54,6 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -54,6 +54,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor
@ -331,6 +332,7 @@ class MessagesPresenter @AssistedInject constructor( @@ -331,6 +332,7 @@ class MessagesPresenter @AssistedInject constructor(
type = AttachmentThumbnailType.Location,
)
is TimelineItemPollContent, // TODO Polls: handle reply to
is TimelineItemVoiceContent, // TODO Voice messages: handle reply to
is TimelineItemTextBasedContent,
is TimelineItemRedactedContent,
is TimelineItemStateContent,

18
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt

@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.features.preferences.api.store.PreferencesStore
@ -131,6 +132,23 @@ class ActionListPresenter @Inject constructor( @@ -131,6 +132,23 @@ class ActionListPresenter @Inject constructor(
}
}
}
is TimelineItemVoiceContent -> {
buildList {
if (timelineItem.isRemote) {
add(TimelineItemAction.Reply)
add(TimelineItemAction.Forward)
}
if (isDeveloperModeEnabled) {
add(TimelineItemAction.ViewSource)
}
if (!timelineItem.isMine) {
add(TimelineItemAction.ReportContent)
}
if (timelineItem.isMine || userCanRedact) {
add(TimelineItemAction.Redact)
}
}
}
else -> buildList<TimelineItemAction> {
if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt

@ -66,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -66,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@ -237,6 +238,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif @@ -237,6 +238,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
when (event.content) {
is TimelineItemPollContent, // TODO Polls: handle summary
is TimelineItemVoiceContent, // TODO Voice messages: handle reply summary
is TimelineItemTextBasedContent,
is TimelineItemStateContent,
is TimelineItemEncryptedContent,

192
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt

@ -0,0 +1,192 @@ @@ -0,0 +1,192 @@
/*
* Copyright (c) 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 io.element.android.features.messages.impl.mediaplayer
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* A media player for Element X.
*/
interface MediaPlayer : AutoCloseable {
/**
* The current state of the player.
*/
val state: StateFlow<State>
/**
* Acquires control of the player and starts playing the given media.
*/
fun acquireControlAndPlay(
uri: String,
mediaId: String,
mimeType: String,
)
/**
* Plays the current media.
*/
fun play()
/**
* Pauses the current media.
*/
fun pause()
/**
* Seeks the current media to the given position.
*/
fun seekTo(positionMs: Long)
/**
* Releases any resources associated with this player.
*/
override fun close()
data class State(
/**
* Whether the player is currently playing.
*/
val isPlaying: Boolean,
/**
* The id of the media which is currently playing.
*
* NB: This is usually the string representation of the [EventId] of the event
* which contains the media.
*/
val mediaId: String?,
/**
* The current position of the player.
*/
val currentPosition: Long,
)
}
/**
* Default implementation of [MediaPlayer] backed by a [SimplePlayer].
*/
@ContributesBinding(RoomScope::class)
@SingleIn(RoomScope::class)
class MediaPlayerImpl @Inject constructor(
private val player: SimplePlayer,
) : MediaPlayer {
private val listener = object : SimplePlayer.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_state.update {
it.copy(
currentPosition = player.currentPosition,
isPlaying = isPlaying,
)
}
if (isPlaying) {
job = scope.launch { updateCurrentPosition() }
} else {
job?.cancel()
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?) {
_state.update {
it.copy(
currentPosition = player.currentPosition,
mediaId = mediaItem?.mediaId,
)
}
}
}
init {
player.addListener(listener)
}
private val scope = CoroutineScope(Job() + Dispatchers.Main)
private var job: Job? = null
private val _state = MutableStateFlow(MediaPlayer.State(false, null, 0L))
override val state: StateFlow<MediaPlayer.State> = _state.asStateFlow()
override fun acquireControlAndPlay(uri: String, mediaId: String, mimeType: String) {
player.clearMediaItems()
player.setMediaItem(
MediaItem.Builder()
.setUri(uri)
.setMediaId(mediaId)
.setMimeType(mimeType)
.build()
)
player.prepare()
player.play()
}
override fun play() {
if (player.playbackState == Player.STATE_ENDED) {
// There's a bug with some ogg files that somehow report to
// have no duration.
// With such files, once playback has ended once, calling
// player.seekTo(0) and then player.play() results in the
// player starting and stopping playing immediately effectively
// playing no sound.
// This is a workaround which will reload the media file.
player.getCurrentMediaItem()?.let {
player.setMediaItem(it)
player.prepare()
player.play()
}
} else {
player.play()
}
}
override fun pause() {
player.pause()
}
override fun seekTo(positionMs: Long) {
player.seekTo(positionMs)
}
override fun close() {
player.release()
}
private suspend fun updateCurrentPosition() {
while (true) {
if (!_state.value.isPlaying) return
delay(100)
_state.update {
it.copy(currentPosition = player.currentPosition)
}
}
}
}

92
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/SimplePlayer.kt

@ -0,0 +1,92 @@ @@ -0,0 +1,92 @@
/*
* Copyright (c) 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 io.element.android.features.messages.impl.mediaplayer
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
/**
* A subset of media3 [Player] that only exposes the few methods we need making it easier to mock.
*/
interface SimplePlayer {
fun addListener(listener: Listener)
val currentPosition: Long
val playbackState: Int
fun clearMediaItems()
fun setMediaItem(mediaItem: MediaItem)
fun getCurrentMediaItem(): MediaItem?
fun prepare()
fun play()
fun pause()
fun seekTo(positionMs: Long)
fun release()
interface Listener {
fun onIsPlayingChanged(isPlaying: Boolean)
fun onMediaItemTransition(mediaItem: MediaItem?)
}
}
@ContributesTo(RoomScope::class)
@Module
object SimplePlayerModule {
@Provides
fun simplePlayerProvider(
@ApplicationContext context: Context,
): SimplePlayer = SimplePlayerImpl(ExoPlayer.Builder(context).build())
}
/**
* Default implementation of [SimplePlayer] backed by a media3 [Player].
*/
class SimplePlayerImpl(
private val p: Player
) : SimplePlayer {
override fun addListener(listener: SimplePlayer.Listener) {
p.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) = listener.onIsPlayingChanged(isPlaying)
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) = listener.onMediaItemTransition(mediaItem)
})
}
override val currentPosition: Long
get() = p.currentPosition
override val playbackState: Int
get() = p.playbackState
override fun clearMediaItems() = p.clearMediaItems()
override fun setMediaItem(mediaItem: MediaItem) = p.setMediaItem(mediaItem)
override fun getCurrentMediaItem(): MediaItem? = p.currentMediaItem
override fun prepare() = p.prepare()
override fun play() = p.play()
override fun pause() = p.pause()
override fun seekTo(positionMs: Long) = p.seekTo(positionMs)
override fun release() = p.release()
}

15
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt

@ -20,6 +20,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource @@ -20,6 +20,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.rememberPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@ -32,6 +34,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -32,6 +34,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.libraries.architecture.Presenter
@Composable
fun TimelineItemEventContentView(
@ -44,6 +49,7 @@ fun TimelineItemEventContentView( @@ -44,6 +49,7 @@ fun TimelineItemEventContentView(
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
) {
val presenterFactories = LocalTimelineItemPresenterFactories.current
when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content,
@ -100,5 +106,14 @@ fun TimelineItemEventContentView( @@ -100,5 +106,14 @@ fun TimelineItemEventContentView(
eventSink = eventSink,
modifier = modifier,
)
is TimelineItemVoiceContent -> {
val presenter: Presenter<VoiceMessageState> = presenterFactories.rememberPresenter(content)
TimelineItemVoiceView(
state = presenter.present(),
content = content,
extraPadding = extraPadding,
modifier = modifier
)
}
}
}

238
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt

@ -0,0 +1,238 @@ @@ -0,0 +1,238 @@
/*
* Copyright (c) 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 io.element.android.features.messages.impl.timeline.components.event
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageStateProvider
import io.element.android.features.messages.impl.voicemessages.timeline.WaveformProgressIndicator
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun TimelineItemVoiceView(
state: VoiceMessageState,
content: TimelineItemVoiceContent,
extraPadding: ExtraPadding,
modifier: Modifier = Modifier,
) {
fun playPause() {
state.eventSink(VoiceMessageEvents.PlayPause)
}
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(ElementTheme.materialColors.background),
contentAlignment = Alignment.Center,
) {
when (state.button) {
VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
VoiceMessageState.Button.Downloading -> ProgressButton()
VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
VoiceMessageState.Button.Disabled -> DisabledPlayButton()
}
}
Spacer(Modifier.width(8.dp))
Text(
text = state.time,
color = ElementTheme.materialColors.secondary,
style = ElementTheme.typography.fontBodySmRegular,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(Modifier.width(8.dp))
WaveformProgressIndicator(
modifier = Modifier
.height(34.dp)
.weight(1f),
progress = state.progress,
amplitudes = content.waveform,
onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) }
)
Spacer(Modifier.width(extraPadding.getDpSize()))
}
}
@Composable
private fun PlayButton(
onClick: (() -> Unit)
) {
IconButton(
drawableRes = R.drawable.play,
contentDescription = stringResource(id = CommonStrings.a11y_play),
onClick = onClick
)
}
@Composable
private fun PauseButton(
onClick: (() -> Unit)
) {
IconButton(
drawableRes = R.drawable.pause,
contentDescription = stringResource(id = CommonStrings.a11y_play),
onClick = onClick
)
}
@Composable
private fun RetryButton(
onClick: (() -> Unit)
) {
IconButton(
drawableRes = R.drawable.retry,
contentDescription = stringResource(id = CommonStrings.action_retry),
onClick = onClick
)
}
@Composable
private fun ProgressButton() {
Button {
CircularProgressIndicator(
modifier = Modifier
.padding(2.dp)
.size(12.dp),
color = ElementTheme.materialColors.primary,
strokeWidth = 1.6.dp,
)
}
}
@Composable
private fun DisabledPlayButton() {
IconButton(
drawableRes = R.drawable.play,
contentDescription = null,
onClick = null,
)
}
@Composable
private fun IconButton(
@DrawableRes drawableRes: Int,
contentDescription: String?,
onClick: (() -> Unit)?,
) {
Button(
onClick = onClick,
) {
Icon(
painter = painterResource(id = drawableRes),
contentDescription = contentDescription,
tint = ElementTheme.materialColors.primary,
modifier = Modifier
.size(16.dp),
)
}
}
@Composable
private fun Button(
onClick: (() -> Unit)? = null,
content: @Composable () -> Unit,
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(ElementTheme.materialColors.background)
.let {
if (onClick != null) it.clickable(onClick = onClick) else it
},
contentAlignment = Alignment.Center,
) {
content()
}
}
open class TimelineItemVoiceViewParametersProvider : PreviewParameterProvider<TimelineItemVoiceViewParameters> {
private val voiceMessageStateProvider = VoiceMessageStateProvider()
private val timelineItemVoiceContentProvider = TimelineItemVoiceContentProvider()
override val values: Sequence<TimelineItemVoiceViewParameters>
get() = voiceMessageStateProvider.values.zip(timelineItemVoiceContentProvider.values)
.map { TimelineItemVoiceViewParameters(it.first, it.second) }
}
data class TimelineItemVoiceViewParameters(
val state: VoiceMessageState,
val content: TimelineItemVoiceContent,
)
@PreviewsDayNight
@Composable
internal fun TimelineItemVoiceViewPreview(
@PreviewParameter(TimelineItemVoiceViewParametersProvider::class) timelineItemVoiceViewParameters: TimelineItemVoiceViewParameters,
) = ElementPreview {
TimelineItemVoiceView(
state = timelineItemVoiceViewParameters.state,
content = timelineItemVoiceViewParameters.content,
extraPadding = noExtraPadding,
)
}
@PreviewsDayNight
@Composable
internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview {
val timelineItemVoiceViewParametersProvider = TimelineItemVoiceViewParametersProvider()
Column {
timelineItemVoiceViewParametersProvider.values.forEach {
TimelineItemVoiceView(
state = it.state,
content = it.content,
extraPadding = noExtraPadding,
)
}
}
}

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt

@ -52,7 +52,7 @@ class TimelineItemContentFactory @Inject constructor( @@ -52,7 +52,7 @@ class TimelineItemContentFactory @Inject constructor(
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> {
val senderDisplayName = (eventTimelineItem.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: eventTimelineItem.sender.value
messageFactory.create(itemContent, senderDisplayName)
messageFactory.create(itemContent, senderDisplayName, eventTimelineItem.eventId)
}
is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)
is RedactedContent -> redactedMessageFactory.create(itemContent)

35
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt

@ -26,10 +26,14 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -26,10 +26,14 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
@ -41,15 +45,18 @@ import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageT @@ -41,15 +45,18 @@ import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import java.time.Duration
import javax.inject.Inject
class TimelineItemContentMessageFactory @Inject constructor(
private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor,
private val featureFlagService: FeatureFlagService,
) {
fun create(content: MessageContent, senderDisplayName: String): TimelineItemEventContent {
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
return when (val messageType = content.type) {
is EmoteMessageType -> TimelineItemEmoteContent(
body = "* $senderDisplayName ${messageType.body}",
@ -103,14 +110,24 @@ class TimelineItemContentMessageFactory @Inject constructor( @@ -103,14 +110,24 @@ class TimelineItemContentMessageFactory @Inject constructor(
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
)
}
is AudioMessageType -> TimelineItemAudioContent(
body = messageType.body,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.body),
)
is AudioMessageType -> when {
featureFlagService.isFeatureEnabled(FeatureFlags.VoiceMessages) && messageType.isVoiceMessage -> TimelineItemVoiceContent(
eventId = eventId,
body = messageType.body,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
waveform = messageType.details?.waveform?.toImmutableList() ?: persistentListOf(),
)
else -> TimelineItemAudioContent(
body = messageType.body,
mediaSource = messageType.source,
duration = messageType.info?.duration ?: Duration.ZERO,
mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
fileExtension = fileExtensionExtractor.extractFromName(messageType.body),
)
}
is FileMessageType -> {
val fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
TimelineItemFileContent(

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt

@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -58,6 +59,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean { @@ -58,6 +59,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemAudioContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
is TimelineItemVoiceContent,
TimelineItemRedactedContent,
TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent,

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt

@ -40,6 +40,7 @@ fun TimelineItemEventContent.canBeCopied(): Boolean = @@ -40,6 +40,7 @@ fun TimelineItemEventContent.canBeCopied(): Boolean =
*/
fun TimelineItemEventContent.canBeRepliedTo(): Boolean =
when (this) {
is TimelineItemVoiceContent, // TODO Voice messages: swipe to reply disabled for now to avoid conflict with audio scrubbing.
is TimelineItemRedactedContent,
is TimelineItemStateContent,
is TimelineItemPollContent -> false
@ -58,6 +59,7 @@ fun TimelineItemEventContent.canReact(): Boolean = @@ -58,6 +59,7 @@ fun TimelineItemEventContent.canReact(): Boolean =
is TimelineItemImageContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
is TimelineItemVoiceContent,
is TimelineItemVideoContent -> true
is TimelineItemStateContent,
is TimelineItemRedactedContent,

33
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
/*
* 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 io.element.android.features.messages.impl.timeline.model.event
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.collections.immutable.ImmutableList
import java.time.Duration
data class TimelineItemVoiceContent(
val eventId: EventId?,
val body: String,
val duration: Duration,
val mediaSource: MediaSource,
val mimeType: String,
val waveform: ImmutableList<Int>,
) : TimelineItemEventContent {
override val type: String = "TimelineItemAudioContent"
}

58
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt

@ -0,0 +1,58 @@ @@ -0,0 +1,58 @@
/*
* Copyright (c) 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 io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.collections.immutable.toPersistentList
import java.time.Duration
open class TimelineItemVoiceContentProvider : PreviewParameterProvider<TimelineItemVoiceContent> {
override val values: Sequence<TimelineItemVoiceContent>
get() = sequenceOf(
aTimelineItemVoiceContent(
durationMs = 1,
waveform = listOf(),
),
aTimelineItemVoiceContent(
durationMs = 10_000,
waveform = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
),
aTimelineItemVoiceContent(
durationMs = 1_800_000, // 30 minutes
waveform = List(1024) { it },
),
)
}
fun aTimelineItemVoiceContent(
eventId: String? = "\$anEventId",
body: String = "body doesn't really matter for a voice message",
durationMs: Long = 61_000,
contentUri: String = "mxc://matrix.org/1234567890abcdefg",
mimeType: String = MimeTypes.Ogg,
waveform: List<Int> = listOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
) = TimelineItemVoiceContent(
eventId = eventId?.let { EventId(it) },
body = body,
duration = Duration.ofMillis(durationMs),
mediaSource = MediaSource(contentUri),
mimeType = mimeType,
waveform = waveform.toPersistentList(),
)

2
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt

@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.ui.strings.CommonStrings
@ -49,6 +50,7 @@ class MessageSummaryFormatterImpl @Inject constructor( @@ -49,6 +50,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemPollContent -> event.content.question
is TimelineItemVoiceContent -> context.getString(CommonStrings.common_voice_message)
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)

123
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageCache.kt

@ -0,0 +1,123 @@ @@ -0,0 +1,123 @@
/*
* Copyright (c) 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 io.element.android.features.messages.impl.voicemessages.timeline
import com.squareup.anvil.annotations.ContributesBinding
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.CacheDirectory
import java.io.File
/**
* Manages the local disk cache for a voice message.
*/
interface VoiceMessageCache {
/**
* Factory for [VoiceMessageCache].
*/
fun interface Factory {
/**
* Creates a [VoiceMessageCache] for the given Matrix Content (mxc://) URI.
*
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
*/
fun create(mxcUri: String): VoiceMessageCache
}
/**
* The file path of the voice message in the cache directory.
* NB: This doesn't necessarily mean that the file exists.
*
* @return the file path of the voice message in the cache directory.
*/
val cachePath: String
/**
* Checks if the voice message is in the cache directory.
*
* @return true if the voice message is in the cache directory.
*/
fun isInCache(): Boolean
/**
* Moves the file to the voice cache directory.
*
* @return true if the file was successfully moved.
*/
fun moveToCache(file: File): Boolean
}
/**
* Default implementation of [VoiceMessageCache].
*
* NB: All methods will throw an [IllegalStateException] if the mxcUri is invalid.
*
* @param cacheDir the application's cache directory.
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
*/
class VoiceMessageCacheImpl @AssistedInject constructor(
@CacheDirectory private val cacheDir: File,
@Assisted private val mxcUri: String,
) : VoiceMessageCache {
@ContributesBinding(AppScope::class)
@AssistedFactory
fun interface Factory : VoiceMessageCache.Factory {
override fun create(mxcUri: String): VoiceMessageCacheImpl
}
override val cachePath: String = "${cacheDir.path}/$CACHE_VOICE_SUBDIR/${mxcUri2FilePath(mxcUri)}"
override fun isInCache(): Boolean = File(cachePath).exists()
override fun moveToCache(file: File): Boolean {
val dest = File(cachePath).apply { parentFile?.mkdirs() }
return file.renameTo(dest)
}
}
/**
* Subdirectory of the application's cache directory where voice messages are stored.
*/
private const val CACHE_VOICE_SUBDIR = "temp/voice"
/**
* Regex to match a Matrix Content (mxc://) URI.
*
* See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris
*/
private val mxcRegex = Regex("""^mxc:\/\/([^\/]+)\/([^\/]+)$""")
/**
* Sanitizes an mxcUri to be used as a relative file path.
*
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
* @return the relative file path as "<server-name>/<media-id>".
* @throws IllegalStateException if the mxcUri is invalid.
*/
private fun mxcUri2FilePath(mxcUri: String): String = checkNotNull(mxcRegex.matchEntire(mxcUri)) {
"mxcUri2FilePath: Invalid mxcUri: $mxcUri"
}.let { match ->
buildString {
append(match.groupValues[1])
append("/")
append(match.groupValues[2])
}
}

22
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
/*
* Copyright (c) 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 io.element.android.features.messages.impl.voicemessages.timeline
sealed interface VoiceMessageEvents {
data object PlayPause : VoiceMessageEvents
data class Seek(val percentage: Float) : VoiceMessageEvents
}

162
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt

@ -0,0 +1,162 @@ @@ -0,0 +1,162 @@
/*
* Copyright (c) 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 io.element.android.features.messages.impl.voicemessages.timeline
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import javax.inject.Inject
/**
* A media player specialized in playing a single voice message.
*/
interface VoiceMessagePlayer {
fun interface Factory {
/**
* Creates a [VoiceMessagePlayer].
*
* NB: Different voice messages can use the same content uri (e.g. in case of
* a forward of a voice message),
* therefore the media uri is not enough to uniquely identify a voice message.
* This is why we must provide the eventId as well.
*
* @param eventId The id of the voice message event. If null, a dummy
* player is returned.
* @param mediaPath The path to the voice message's media file.
*/
fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayer
}
/**
* The current state of this player.
*/
val state: Flow<State>
/**
* Start playing from the beginning acquiring control of the
* underlying [MediaPlayer].
*/
fun acquireControlAndPlay()
/**
* Start playing from the current position.
*/
fun play()
/**
* Pause playback.
*/
fun pause()
/**
* Seek to a specific position.
*
* @param positionMs The position in milliseconds.
*/
fun seekTo(positionMs: Long)
data class State(
/**
* Whether this player is currently playing.
*/
val isPlaying: Boolean,
/**
* Whether this player has control of the underlying [MediaPlayer].
*/
val isMyMedia: Boolean,
/**
* The elapsed time of this player in milliseconds.
*/
val currentPosition: Long,
)
}
/**
* An implementation of [VoiceMessagePlayer] which is backed by a [MediaPlayer]
* usually shared among different [VoiceMessagePlayer] instances.
*
* @param mediaPlayer The [MediaPlayer] to use.
* @param eventId The id of the voice message event. If null, the player will behave as no-op.
* @param mediaPath The path to the voice message's media file.
*/
class VoiceMessagePlayerImpl(
private val mediaPlayer: MediaPlayer,
private val eventId: EventId?,
private val mediaPath: String,
) : VoiceMessagePlayer {
@ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject.
class Factory @Inject constructor(
private val mediaPlayer: MediaPlayer,
) : VoiceMessagePlayer.Factory {
override fun create(eventId: EventId?, mediaPath: String): VoiceMessagePlayerImpl {
return VoiceMessagePlayerImpl(
mediaPlayer = mediaPlayer,
eventId = eventId,
mediaPath = mediaPath,
)
}
}
override val state: Flow<VoiceMessagePlayer.State> = mediaPlayer.state.map { state ->
VoiceMessagePlayer.State(
isPlaying = state.mediaId.isMyTrack() && state.isPlaying,
isMyMedia = state.mediaId.isMyTrack(),
currentPosition = if (state.mediaId.isMyTrack()) state.currentPosition else 0L
)
}.distinctUntilChanged()
override fun acquireControlAndPlay() {
eventId?.let { eventId ->
mediaPlayer.acquireControlAndPlay(
uri = mediaPath,
mediaId = eventId.value,
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
)
}
}
override fun play() {
ifInControl {
mediaPlayer.play()
}
}
override fun pause() {
ifInControl {
mediaPlayer.pause()
}
}
override fun seekTo(positionMs: Long) {
ifInControl {
mediaPlayer.seekTo(positionMs)
}
}
private fun String?.isMyTrack(): Boolean = if (eventId == null) false else this == eventId.value
private inline fun ifInControl(block: () -> Unit) {
if (mediaPlayer.state.value.mediaId.isMyTrack()) block()
}
}

151
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt

@ -0,0 +1,151 @@ @@ -0,0 +1,151 @@
/*
* Copyright (c) 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 io.element.android.features.messages.impl.voicemessages.timeline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.multibindings.IntoMap
import io.element.android.features.messages.impl.timeline.di.TimelineItemEventContentKey
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.toFile
import io.element.android.libraries.ui.utils.time.formatShort
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Module
@ContributesTo(RoomScope::class)
interface VoiceMessagePresenterModule {
@Binds
@IntoMap
@TimelineItemEventContentKey(TimelineItemVoiceContent::class)
fun bindVoiceMessagePresenterFactory(factory: VoiceMessagePresenter.Factory): TimelineItemPresenterFactory<*, *>
}
class VoiceMessagePresenter @AssistedInject constructor(
private val mediaLoader: MatrixMediaLoader,
voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
voiceMessageCacheFactory: VoiceMessageCache.Factory,
@Assisted private val content: TimelineItemVoiceContent,
) : Presenter<VoiceMessageState> {
@AssistedFactory
fun interface Factory : TimelineItemPresenterFactory<TimelineItemVoiceContent, VoiceMessageState> {
override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
}
private val voiceCache = voiceMessageCacheFactory.create(mxcUri = content.mediaSource.url)
private val player = voiceMessagePlayerFactory.create(
eventId = content.eventId,
mediaPath = voiceCache.cachePath
)
@Composable
override fun present(): VoiceMessageState {
val scope = rememberCoroutineScope()
val playerState by player.state.collectAsState(VoiceMessagePlayer.State(isPlaying = false, isMyMedia = false, currentPosition = 0L))
val mediaFile = remember { mutableStateOf<Async<MediaFile>>(Async.Uninitialized) }
val button by remember {
derivedStateOf {
when {
content.eventId == null -> VoiceMessageState.Button.Disabled
playerState.isPlaying -> VoiceMessageState.Button.Pause
mediaFile.value is Async.Loading -> VoiceMessageState.Button.Downloading
mediaFile.value is Async.Failure -> VoiceMessageState.Button.Retry
else -> VoiceMessageState.Button.Play
}
}
}
val progress by remember {
derivedStateOf { if (playerState.isMyMedia) playerState.currentPosition / content.duration.toMillis().toFloat() else 0f }
}
val time by remember {
derivedStateOf {
val time = if (playerState.isMyMedia) playerState.currentPosition else content.duration.toMillis()
time.milliseconds.formatShort()
}
}
suspend fun downloadCacheAndPlay() {
mediaFile.runUpdatingState {
mediaLoader.downloadMediaFile(
source = content.mediaSource,
mimeType = content.mimeType,
body = content.body,
).mapCatching {
if (voiceCache.moveToCache(it.toFile())) {
player.acquireControlAndPlay()
it
} else {
error("Failed to move file to cache.")
}
}
}
}
fun eventSink(event: VoiceMessageEvents) {
when (event) {
is VoiceMessageEvents.PlayPause -> {
if (playerState.isMyMedia) {
if (playerState.isPlaying) {
player.pause()
} else {
player.play()
}
} else {
if (voiceCache.isInCache()) {
player.acquireControlAndPlay()
} else {
scope.launch { downloadCacheAndPlay() }
}
}
}
is VoiceMessageEvents.Seek -> {
player.seekTo((event.percentage * content.duration.toMillis()).toLong())
}
}
}
return VoiceMessageState(
button = button,
progress = progress,
time = time,
eventSink = { eventSink(it) },
)
}
}

32
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt

@ -0,0 +1,32 @@ @@ -0,0 +1,32 @@
/*
* Copyright (c) 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 io.element.android.features.messages.impl.voicemessages.timeline
data class VoiceMessageState(
val button: Button,
val progress: Float,
val time: String,
val eventSink: (event: VoiceMessageEvents) -> Unit,
) {
enum class Button {
Play,
Pause,
Downloading,
Retry,
Disabled,
}
}

55
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
/*
* Copyright (c) 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 io.element.android.features.messages.impl.voicemessages.timeline
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
open class VoiceMessageStateProvider : PreviewParameterProvider<VoiceMessageState> {
override val values: Sequence<VoiceMessageState>
get() = sequenceOf(
VoiceMessageState(
VoiceMessageState.Button.Downloading,
progress = 0f,
time = "00:00",
eventSink = {},
),
VoiceMessageState(
VoiceMessageState.Button.Retry,
progress = 0.5f,
time = "00:00",
eventSink = {}
),
VoiceMessageState(
VoiceMessageState.Button.Play,
progress = 1f,
time = "00:00",
eventSink = {}
),
VoiceMessageState(
VoiceMessageState.Button.Pause,
progress = 0.2f,
time = "00:00",
eventSink = {}
),
VoiceMessageState(
VoiceMessageState.Button.Disabled,
progress = 0.2f,
time = "00:00",
eventSink = {}
),
)
}

96
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/WaveformProgressIndicator.kt

@ -0,0 +1,96 @@ @@ -0,0 +1,96 @@
/*
* Copyright (c) 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 io.element.android.features.messages.impl.voicemessages.timeline
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.unit.dp
import com.linc.audiowaveform.AudioWaveform
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList
@Composable
fun WaveformProgressIndicator(
progress: Float,
amplitudes: ImmutableList<Int>,
modifier: Modifier = Modifier,
onSeek: (progress: Float) -> Unit = {},
) {
var seekProgress: Float? by remember { mutableStateOf(null) }
val scaledAmplitudes = remember(amplitudes) { amplitudes.scaleAmplitudes() }
AudioWaveform(
modifier = modifier,
waveformBrush = SolidColor(ElementTheme.colors.iconQuaternary),
progressBrush = SolidColor(ElementTheme.colors.iconSecondary),
onProgressChangeFinished = {
// This is to send just one onSeek callback after the user has finished seeking.
// Otherwise the AudioWaveform library would send multiple callbacks while the user is seeking.
val p = seekProgress!!
seekProgress = null
onSeek(p)
},
spikeWidth = 1.6.dp,
spikeRadius = 0.8.dp,
spikePadding = 3.dp,
progress = seekProgress ?: progress,
amplitudes = scaledAmplitudes,
onProgressChange = { seekProgress = it },
)
}
@PreviewsDayNight
@Composable
internal fun WaveformProgressIndicatorPreview() = ElementPreview {
Column {
WaveformProgressIndicator(
progress = 0.5f,
amplitudes = persistentListOf(),
)
WaveformProgressIndicator(
progress = 0.5f,
amplitudes = persistentListOf(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0),
)
WaveformProgressIndicator(
progress = 0.5f,
amplitudes = List(1024) { it }.toPersistentList()
)
}
}
/**
* Scale amplitudes to fit in the waveform view.
*
* It seems amplitudes > 128 are clipped by the waveform library.
* Workaround for https://github.com/lincollincol/compose-audiowaveform/issues/22
*
* TODO Voice messages: Remove this workaround when the waveform library is fixed.
*/
private fun ImmutableList<Int>.scaleAmplitudes(): List<Int> {
val maxAmplitude = if (isEmpty()) 1 else maxOf { it }
val scalingFactor = 128 / maxAmplitude.toFloat()
return map { (it * scalingFactor).toInt() }
}

9
features/messages/impl/src/main/res/drawable/pause.xml

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M16,19C15.45,19 14.979,18.804 14.587,18.413C14.196,18.021 14,17.55 14,17V7C14,6.45 14.196,5.979 14.587,5.588C14.979,5.196 15.45,5 16,5C16.55,5 17.021,5.196 17.413,5.588C17.804,5.979 18,6.45 18,7V17C18,17.55 17.804,18.021 17.413,18.413C17.021,18.804 16.55,19 16,19ZM8,19C7.45,19 6.979,18.804 6.588,18.413C6.196,18.021 6,17.55 6,17V7C6,6.45 6.196,5.979 6.588,5.588C6.979,5.196 7.45,5 8,5C8.55,5 9.021,5.196 9.413,5.588C9.804,5.979 10,6.45 10,7V17C10,17.55 9.804,18.021 9.413,18.413C9.021,18.804 8.55,19 8,19Z"
android:fillColor="#656D77"/>
</vector>

9
features/messages/impl/src/main/res/drawable/play.xml

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M9.525,18.025C9.192,18.242 8.854,18.254 8.512,18.063C8.171,17.871 8,17.575 8,17.175V6.825C8,6.425 8.171,6.129 8.512,5.938C8.854,5.746 9.192,5.759 9.525,5.975L17.675,11.15C17.975,11.35 18.125,11.634 18.125,12C18.125,12.367 17.975,12.65 17.675,12.85L9.525,18.025Z"
android:fillColor="#656D77"/>
</vector>

9
features/messages/impl/src/main/res/drawable/retry.xml

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M12,20C9.767,20 7.875,19.225 6.325,17.675C4.775,16.125 4,14.233 4,12C4,9.767 4.775,7.875 6.325,6.325C7.875,4.775 9.767,4 12,4C13.15,4 14.25,4.238 15.3,4.713C16.35,5.188 17.25,5.867 18,6.75V5C18,4.717 18.096,4.479 18.288,4.287C18.479,4.096 18.717,4 19,4C19.283,4 19.521,4.096 19.712,4.287C19.904,4.479 20,4.717 20,5V10C20,10.283 19.904,10.521 19.712,10.712C19.521,10.904 19.283,11 19,11H14C13.717,11 13.479,10.904 13.288,10.712C13.096,10.521 13,10.283 13,10C13,9.717 13.096,9.479 13.288,9.288C13.479,9.096 13.717,9 14,9H17.2C16.667,8.067 15.938,7.333 15.012,6.8C14.087,6.267 13.083,6 12,6C10.333,6 8.917,6.583 7.75,7.75C6.583,8.917 6,10.333 6,12C6,13.667 6.583,15.083 7.75,16.25C8.917,17.417 10.333,18 12,18C13.133,18 14.171,17.712 15.113,17.138C16.054,16.563 16.783,15.792 17.3,14.825C17.433,14.592 17.621,14.429 17.862,14.337C18.104,14.246 18.35,14.242 18.6,14.325C18.867,14.408 19.058,14.583 19.175,14.85C19.292,15.117 19.283,15.367 19.15,15.6C18.467,16.933 17.492,18 16.225,18.8C14.958,19.6 13.55,20 12,20Z"
android:fillColor="#656D77"/>
</vector>

28
features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt

@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.tests.testutils.WarmUpRule
@ -458,6 +459,33 @@ class ActionListPresenterTest { @@ -458,6 +459,33 @@ class ActionListPresenterTest {
assertThat(successState.displayEmojiReactions).isTrue()
}
}
@Test
fun `present - compute for voice message`() = runTest {
val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = aTimelineItemVoiceContent(),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
val successState = awaitItem()
assertThat(successState.target).isEqualTo(
ActionListState.Target.Success(
messageEvent,
persistentListOf(
TimelineItemAction.Reply,
TimelineItemAction.Forward,
TimelineItemAction.Redact,
)
)
)
assertThat(successState.displayEmojiReactions).isTrue()
}
}
}
private fun createActionListPresenter(isDeveloperModeEnabled: Boolean): ActionListPresenter {

6
features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt

@ -49,7 +49,11 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory { @@ -49,7 +49,11 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory(
contentFactory = TimelineItemContentFactory(
messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()),
messageFactory = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = FakeFeatureFlagService(),
),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),
pollFactory = TimelineItemContentPollFactory(matrixClient, FakeFeatureFlagService()),

71
features/messages/impl/src/test/kotlin/io/element/android/features/messages/mediaplayer/FakeMediaPlayer.kt

@ -0,0 +1,71 @@ @@ -0,0 +1,71 @@
/*
* Copyright (c) 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 io.element.android.features.messages.mediaplayer
import io.element.android.features.messages.impl.mediaplayer.MediaPlayer
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
/**
* Fake implementation of [MediaPlayer] for testing purposes.
*/
class FakeMediaPlayer : MediaPlayer {
private val _state = MutableStateFlow(MediaPlayer.State(false, null, 0L))
override val state: StateFlow<MediaPlayer.State> = _state.asStateFlow()
override fun acquireControlAndPlay(uri: String, mediaId: String, mimeType: String) {
_state.update {
it.copy(
isPlaying = true,
mediaId = mediaId,
currentPosition = it.currentPosition + 1000L,
)
}
}
override fun play() {
_state.update {
it.copy(
isPlaying = true,
currentPosition = it.currentPosition + 1000L,
)
}
}
override fun pause() {
_state.update {
it.copy(
isPlaying = false,
)
}
}
override fun seekTo(positionMs: Long) {
_state.update {
it.copy(
currentPosition = positionMs,
)
}
}
override fun close() {
// no-op
}
}

49
features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/FakeVoiceMessageCache.kt

@ -0,0 +1,49 @@ @@ -0,0 +1,49 @@
/*
* Copyright (c) 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 io.element.android.features.messages.voicemessages.timeline
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageCache
import java.io.File
/**
* A fake implementation of [VoiceMessageCache] for testing purposes.
*/
class FakeVoiceMessageCache : VoiceMessageCache {
private var _cachePath: String = ""
private var _isInCache: Boolean = false
private var _moveToCache: Boolean = false
override val cachePath: String
get() = _cachePath
override fun isInCache(): Boolean = _isInCache
override fun moveToCache(file: File): Boolean = _moveToCache
fun givenCachePath(cachePath: String) {
_cachePath = cachePath
}
fun givenIsInCache(isInCache: Boolean) {
_isInCache = isInCache
}
fun givenMoveToCache(moveToCache: Boolean) {
_moveToCache = moveToCache
}
}

90
features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessageCacheTest.kt

@ -0,0 +1,90 @@ @@ -0,0 +1,90 @@
/*
* Copyright (c) 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 io.element.android.features.messages.voicemessages.timeline
import com.google.common.truth.Truth
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageCacheImpl
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class VoiceMessageCacheTest {
@get:Rule
val temporaryFolder = TemporaryFolder()
@Test
fun `moveToVoiceCache() should move the file to the voice cache dir`() {
val rootPath = temporaryFolder.root.path
val file = File("$rootPath/myFile.txt").apply { createNewFile() }
val cacheDir = File("$rootPath/cacheDir").apply { if (!exists()) mkdirs() }
val mxcUri = "mxc://matrix.org/1234567890abcdefg"
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri)
Truth.assertThat(cache.moveToCache(file))
.isTrue()
Truth.assertThat(File("$rootPath/cacheDir/temp/voice/matrix.org/1234567890abcdefg").exists())
.isTrue()
}
@Test
fun `voiceCachePath() should point to cacheDir-temp-voice-mxcUri2fileName`() {
val rootPath = temporaryFolder.root.path
val cacheDir = File("$rootPath/cacheDir")
val mxcUri = "mxc://matrix.org/1234567890abcdefg"
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri)
Truth.assertThat(cache.cachePath)
.isEqualTo("$rootPath/cacheDir/temp/voice/matrix.org/1234567890abcdefg")
}
@Test
fun `isInVoiceCache() should return true if the file exists`() {
val rootPath = temporaryFolder.root.path
val cacheDir = File("$rootPath/cacheDir")
val mxcUri = "mxc://matrix.org/1234567890abcdefg"
val file = File("$rootPath/cacheDir/temp/voice/matrix.org/1234567890abcdefg").apply {
parentFile?.mkdirs()
createNewFile()
}
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri)
Truth.assertThat(cache.isInCache())
.isTrue()
}
@Test
fun `isInVoiceCache() should return false if the file does not exist`() {
val rootPath = temporaryFolder.root.path
val cacheDir = File("$rootPath/cacheDir")
val mxcUri = "mxc://matrix.org/1234567890abcdefg"
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri)
Truth.assertThat(cache.isInCache())
.isFalse()
}
@Test(expected = IllegalStateException::class)
fun `isInVoiceCache() throws IllegalStateException on bogus mxc uri`() {
val cacheDir = File("")
val mxcUri = "bogus"
val cache = VoiceMessageCacheImpl(cacheDir, mxcUri)
cache.isInCache()
}
}

294
features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt

@ -0,0 +1,294 @@ @@ -0,0 +1,294 @@
/*
* Copyright (c) 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 io.element.android.features.messages.voicemessages.timeline
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.features.messages.mediaplayer.FakeMediaPlayer
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessagePlayerImpl
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessagePresenter
import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import kotlinx.coroutines.test.runTest
import org.junit.Test
class VoiceMessagePresenterTest {
private val fakeMediaLoader = FakeMediaLoader()
private val fakeVoiceCache = FakeVoiceMessageCache()
@Test
fun `initial state has proper default values`() = runTest {
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem().let {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("1:01")
}
}
}
@Test
fun `pressing play with file in cache plays`() = runTest {
fakeVoiceCache.apply {
givenIsInCache(true)
}
val content = aTimelineItemVoiceContent(durationMs = 2_000)
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("0:02")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
Truth.assertThat(it.progress).isEqualTo(0.5f)
Truth.assertThat(it.time).isEqualTo("0:01")
}
}
}
@Test
fun `pressing play with file not in cache downloads it but fails`() = runTest {
fakeMediaLoader.apply {
shouldFail = true
}
fakeVoiceCache.apply {
givenIsInCache(false)
givenMoveToCache(true)
}
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("1:01")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("1:01")
}
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("1:01")
}
}
}
@Test
fun `pressing play with file not in cache downloads it but then caching fails`() = runTest {
fakeMediaLoader.apply {
shouldFail = false
}
fakeVoiceCache.apply {
givenIsInCache(false)
givenMoveToCache(false)
}
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("1:01")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("1:01")
}
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("1:01")
}
}
}
@Test
fun `acquire control then play then play and pause while having control`() = runTest {
fakeVoiceCache.apply {
givenIsInCache(true)
}
val content = aTimelineItemVoiceContent(durationMs = 2_000)
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("0:02")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
Truth.assertThat(it.progress).isEqualTo(0.5f)
Truth.assertThat(it.time).isEqualTo("0:01")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
Truth.assertThat(it.progress).isEqualTo(0.5f)
Truth.assertThat(it.time).isEqualTo("0:01")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
Truth.assertThat(it.progress).isEqualTo(1.0f)
Truth.assertThat(it.time).isEqualTo("0:02")
}
}
}
@Test
fun `pressing play with file not in cache downloads it successfully`() = runTest {
fakeMediaLoader.apply {
shouldFail = false
}
fakeVoiceCache.apply {
givenIsInCache(false)
givenMoveToCache(true)
}
val content = aTimelineItemVoiceContent(durationMs = 2_000)
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("0:02")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("0:02")
}
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
Truth.assertThat(it.progress).isEqualTo(0.5f)
Truth.assertThat(it.time).isEqualTo("0:01")
}
}
}
@Test
fun `content with null eventId shows disabled button`() = runTest {
fakeMediaLoader.apply {
shouldFail = false
}
fakeVoiceCache.apply {
givenIsInCache(false)
givenMoveToCache(true)
}
val content = aTimelineItemVoiceContent(eventId = null)
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("1:01")
}
}
}
@Test
fun `seeking seeks`() = runTest {
fakeVoiceCache.apply {
givenIsInCache(true)
}
val content = aTimelineItemVoiceContent(durationMs = 10_000)
val presenter = createVoiceMessagePresenter(fakeMediaLoader, fakeVoiceCache, content)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("0:10")
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
Truth.assertThat(it.progress).isEqualTo(0.1f)
Truth.assertThat(it.time).isEqualTo("0:01")
}
initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
Truth.assertThat(it.progress).isEqualTo(0.5f)
Truth.assertThat(it.time).isEqualTo("0:05")
}
}
}
}
fun createVoiceMessagePresenter(
fakeMediaLoader: FakeMediaLoader,
voiceCacheFake: FakeVoiceMessageCache,
content: TimelineItemVoiceContent = aTimelineItemVoiceContent(),
) = VoiceMessagePresenter(
mediaLoader = fakeMediaLoader,
voiceMessagePlayerFactory = { eventId, mediaPath -> VoiceMessagePlayerImpl(FakeMediaPlayer(), eventId, mediaPath) },
voiceMessageCacheFactory = { voiceCacheFake },
content = content,
)

1
gradle/libs.versions.toml

@ -166,6 +166,7 @@ maplibre = "org.maplibre.gl:android-sdk:10.2.0" @@ -166,6 +166,7 @@ maplibre = "org.maplibre.gl:android-sdk:10.2.0"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.1"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:2.0.1"
opusencoder = "io.element.android:opusencoder:1.1.0"
audiowaveform = "com.github.lincollincol:compose-audiowaveform:1.1.1"
# Analytics
posthog = "com.posthog.android:posthog:2.0.3"

1
settings.gradle.kts

@ -35,6 +35,7 @@ dependencyResolutionManagement { @@ -35,6 +35,7 @@ dependencyResolutionManagement {
content {
includeModule("com.github.UnifiedPush", "android-connector")
includeModule("com.github.matrix-org", "matrix-analytics-events")
includeModule("com.github.lincollincol", "compose-audiowaveform")
}
}
// To have immediate access to Rust SDK versions

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceView-D-39_39_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceView-D-39_39_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceView-D-39_39_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceView-N-39_40_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceView-N-39_40_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceView-N-39_40_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceViewUnified-D-40_40_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_null_TimelineItemVoiceViewUnified-N-40_41_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.group_null_GroupHeaderView-D-41_41_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.group_null_GroupHeaderView-N-41_42_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_10,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_11,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_12,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_13,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_14,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_15,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_16,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_17,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_18,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_19,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_20,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_21,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_6,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_7,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_8,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-D-42_42_null_9,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_10,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_11,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_12,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_13,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_14,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_15,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_16,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_17,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_18,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_19,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_20,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_21,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_6,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_7,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_8,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_null_HtmlDocument-N-42_43_null_9,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.reactionsummary_null_SheetContent-D-43_43_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.reactionsummary_null_SheetContent-N-43_44_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.retrysendmenu_null_RetrySendMessageMenu-D-44_44_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.retrysendmenu_null_RetrySendMessageMenu-D-44_44_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.retrysendmenu_null_RetrySendMessageMenu-N-44_45_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.retrysendmenu_null_RetrySendMessageMenu-N-44_45_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineEncryptedHistoryBannerView-D-45_45_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineEncryptedHistoryBannerView-N-45_46_null,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineItemDaySeparatorView-D-46_46_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineItemDaySeparatorView-D-46_46_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineItemDaySeparatorView-N-46_47_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save