From 6e66c989f4fbf24d5c7821c24db32ac31a44eab2 Mon Sep 17 00:00:00 2001 From: Marco Romano Date: Tue, 24 Oct 2023 23:47:51 +0200 Subject: [PATCH] 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. --- changelog.d/2084.feature | 1 + features/messages/impl/build.gradle.kts | 2 + .../features/messages/impl/MessagesNode.kt | 7 +- .../messages/impl/MessagesPresenter.kt | 2 + .../impl/actionlist/ActionListPresenter.kt | 18 ++ .../impl/actionlist/ActionListView.kt | 2 + .../messages/impl/mediaplayer/MediaPlayer.kt | 192 ++++++++++++ .../messages/impl/mediaplayer/SimplePlayer.kt | 92 ++++++ .../event/TimelineItemEventContentView.kt | 15 + .../components/event/TimelineItemVoiceView.kt | 238 ++++++++++++++ .../event/TimelineItemContentFactory.kt | 2 +- .../TimelineItemContentMessageFactory.kt | 35 ++- .../impl/timeline/groups/Groupability.kt | 2 + .../model/event/TimelineItemEventContent.kt | 2 + .../model/event/TimelineItemVoiceContent.kt | 33 ++ .../event/TimelineItemVoiceContentProvider.kt | 58 ++++ .../MessageSummaryFormatterImpl.kt | 2 + .../timeline/VoiceMessageCache.kt | 123 ++++++++ .../timeline/VoiceMessageEvents.kt | 22 ++ .../timeline/VoiceMessagePlayer.kt | 162 ++++++++++ .../timeline/VoiceMessagePresenter.kt | 151 +++++++++ .../timeline/VoiceMessageState.kt | 32 ++ .../timeline/VoiceMessageStateProvider.kt | 55 ++++ .../timeline/WaveformProgressIndicator.kt | 96 ++++++ .../impl/src/main/res/drawable/pause.xml | 9 + .../impl/src/main/res/drawable/play.xml | 9 + .../impl/src/main/res/drawable/retry.xml | 9 + .../actionlist/ActionListPresenterTest.kt | 28 ++ .../messages/fixtures/timelineItemsFactory.kt | 6 +- .../messages/mediaplayer/FakeMediaPlayer.kt | 71 +++++ .../timeline/FakeVoiceMessageCache.kt | 49 +++ .../timeline/VoiceMessageCacheTest.kt | 90 ++++++ .../timeline/VoiceMessagePresenterTest.kt | 294 ++++++++++++++++++ gradle/libs.versions.toml | 1 + settings.gradle.kts | 1 + ...iceView-D-39_39_null_0,NEXUS_5,1.0,en].png | 3 + ...iceView-D-39_39_null_1,NEXUS_5,1.0,en].png | 3 + ...iceView-D-39_39_null_2,NEXUS_5,1.0,en].png | 3 + ...iceView-N-39_40_null_0,NEXUS_5,1.0,en].png | 3 + ...iceView-N-39_40_null_1,NEXUS_5,1.0,en].png | 3 + ...iceView-N-39_40_null_2,NEXUS_5,1.0,en].png | 3 + ...ewUnified-D-40_40_null,NEXUS_5,1.0,en].png | 3 + ...ewUnified-N-40_41_null,NEXUS_5,1.0,en].png | 3 + ...eaderView-D-41_41_null,NEXUS_5,1.0,en].png | 3 + ...eaderView-N-41_42_null,NEXUS_5,1.0,en].png | 3 + ...ocument-D-42_42_null_0,NEXUS_5,1.0,en].png | 3 + ...ocument-D-42_42_null_1,NEXUS_5,1.0,en].png | 3 + ...cument-D-42_42_null_10,NEXUS_5,1.0,en].png | 3 + ...cument-D-42_42_null_11,NEXUS_5,1.0,en].png | 3 + ...cument-D-42_42_null_12,NEXUS_5,1.0,en].png | 3 + ...cument-D-42_42_null_13,NEXUS_5,1.0,en].png | 3 + ...cument-D-42_42_null_14,NEXUS_5,1.0,en].png | 3 + ...cument-D-42_42_null_15,NEXUS_5,1.0,en].png | 3 + ...cument-D-42_42_null_16,NEXUS_5,1.0,en].png | 3 + ...cument-D-42_42_null_17,NEXUS_5,1.0,en].png | 3 + ...cument-D-42_42_null_18,NEXUS_5,1.0,en].png | 3 + ...cument-D-42_42_null_19,NEXUS_5,1.0,en].png | 3 + ...ocument-D-42_42_null_2,NEXUS_5,1.0,en].png | 3 + ...cument-D-42_42_null_20,NEXUS_5,1.0,en].png | 3 + ...cument-D-42_42_null_21,NEXUS_5,1.0,en].png | 3 + ...ocument-D-42_42_null_3,NEXUS_5,1.0,en].png | 3 + ...ocument-D-42_42_null_4,NEXUS_5,1.0,en].png | 3 + ...ocument-D-42_42_null_5,NEXUS_5,1.0,en].png | 3 + ...ocument-D-42_42_null_6,NEXUS_5,1.0,en].png | 3 + ...ocument-D-42_42_null_7,NEXUS_5,1.0,en].png | 3 + ...ocument-D-42_42_null_8,NEXUS_5,1.0,en].png | 3 + ...ocument-D-42_42_null_9,NEXUS_5,1.0,en].png | 3 + ...ocument-N-42_43_null_0,NEXUS_5,1.0,en].png | 3 + ...ocument-N-42_43_null_1,NEXUS_5,1.0,en].png | 3 + ...cument-N-42_43_null_10,NEXUS_5,1.0,en].png | 3 + ...cument-N-42_43_null_11,NEXUS_5,1.0,en].png | 3 + ...cument-N-42_43_null_12,NEXUS_5,1.0,en].png | 3 + ...cument-N-42_43_null_13,NEXUS_5,1.0,en].png | 3 + ...cument-N-42_43_null_14,NEXUS_5,1.0,en].png | 3 + ...cument-N-42_43_null_15,NEXUS_5,1.0,en].png | 3 + ...cument-N-42_43_null_16,NEXUS_5,1.0,en].png | 3 + ...cument-N-42_43_null_17,NEXUS_5,1.0,en].png | 3 + ...cument-N-42_43_null_18,NEXUS_5,1.0,en].png | 3 + ...cument-N-42_43_null_19,NEXUS_5,1.0,en].png | 3 + ...ocument-N-42_43_null_2,NEXUS_5,1.0,en].png | 3 + ...cument-N-42_43_null_20,NEXUS_5,1.0,en].png | 3 + ...cument-N-42_43_null_21,NEXUS_5,1.0,en].png | 3 + ...ocument-N-42_43_null_3,NEXUS_5,1.0,en].png | 3 + ...ocument-N-42_43_null_4,NEXUS_5,1.0,en].png | 3 + ...ocument-N-42_43_null_5,NEXUS_5,1.0,en].png | 3 + ...ocument-N-42_43_null_6,NEXUS_5,1.0,en].png | 3 + ...ocument-N-42_43_null_7,NEXUS_5,1.0,en].png | 3 + ...ocument-N-42_43_null_8,NEXUS_5,1.0,en].png | 3 + ...ocument-N-42_43_null_9,NEXUS_5,1.0,en].png | 3 + ...Content-D-43_43_null_0,NEXUS_5,1.0,en].png | 3 + ...Content-N-43_44_null_0,NEXUS_5,1.0,en].png | 3 + ...ageMenu-D-44_44_null_0,NEXUS_5,1.0,en].png | 3 + ...ageMenu-D-44_44_null_1,NEXUS_5,1.0,en].png | 3 + ...ageMenu-N-44_45_null_0,NEXUS_5,1.0,en].png | 3 + ...ageMenu-N-44_45_null_1,NEXUS_5,1.0,en].png | 3 + ...annerView-D-45_45_null,NEXUS_5,1.0,en].png | 3 + ...annerView-N-45_46_null,NEXUS_5,1.0,en].png | 3 + ...torView-D-46_46_null_0,NEXUS_5,1.0,en].png | 3 + ...torView-D-46_46_null_1,NEXUS_5,1.0,en].png | 3 + ...torView-N-46_47_null_0,NEXUS_5,1.0,en].png | 3 + ...torView-N-46_47_null_1,NEXUS_5,1.0,en].png | 3 + ...Indicator-D-47_47_null,NEXUS_5,1.0,en].png | 3 + ...Indicator-N-47_48_null,NEXUS_5,1.0,en].png | 3 + ...gInfoView-D-48_48_null,NEXUS_5,1.0,en].png | 3 + ...gInfoView-N-48_49_null,NEXUS_5,1.0,en].png | 3 + ...Indicator-D-49_49_null,NEXUS_5,1.0,en].png | 3 + ...Indicator-N-49_50_null,NEXUS_5,1.0,en].png | 3 + 107 files changed, 2115 insertions(+), 12 deletions(-) create mode 100644 changelog.d/2084.feature create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/SimplePlayer.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageCache.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt create mode 100644 features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/WaveformProgressIndicator.kt create mode 100644 features/messages/impl/src/main/res/drawable/pause.xml create mode 100644 features/messages/impl/src/main/res/drawable/play.xml create mode 100644 features/messages/impl/src/main/res/drawable/retry.xml create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/mediaplayer/FakeMediaPlayer.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/FakeVoiceMessageCache.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessageCacheTest.kt create mode 100644 features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 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 create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineItemDaySeparatorView-N-46_47_null_1,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineLoadingMoreIndicator-D-47_47_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineLoadingMoreIndicator-N-47_48_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.debug_null_EventDebugInfoView-D-48_48_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.debug_null_EventDebugInfoView-N-48_49_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.voicemessages.timeline_null_WaveformProgressIndicator-D-49_49_null,NEXUS_5,1.0,en].png create mode 100644 tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.voicemessages.timeline_null_WaveformProgressIndicator-N-49_50_null,NEXUS_5,1.0,en].png diff --git a/changelog.d/2084.feature b/changelog.d/2084.feature new file mode 100644 index 0000000000..ddb6425ea7 --- /dev/null +++ b/changelog.d/2084.feature @@ -0,0 +1 @@ +Receive and play a voice message diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 8ee3adb13c..db625e5b74 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -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 { implementation(libs.vanniktech.blurhash) implementation(libs.telephoto.zoomableimage) implementation(libs.matrix.emojibase.bindings) + implementation(libs.audiowaveform) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 50b59afbcc..2263fe51cb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -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( 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( lifecycle.subscribe( onCreate = { analyticsService.capture(room.toAnalyticsViewRoom()) + }, + onDestroy = { + mediaPlayer.close() } ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index 5686de8026..adae018fee 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/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 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( type = AttachmentThumbnailType.Location, ) is TimelineItemPollContent, // TODO Polls: handle reply to + is TimelineItemVoiceContent, // TODO Voice messages: handle reply to is TimelineItemTextBasedContent, is TimelineItemRedactedContent, is TimelineItemStateContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt index c9e485b88a..cd11aa875b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt +++ b/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 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( } } } + 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 { if (timelineItem.isRemote) { // Can only reply or forward messages already uploaded to the server diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 34d87201a2..c5e6e5facd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/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 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 when (event.content) { is TimelineItemPollContent, // TODO Polls: handle summary + is TimelineItemVoiceContent, // TODO Voice messages: handle reply summary is TimelineItemTextBasedContent, is TimelineItemStateContent, is TimelineItemEncryptedContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt new file mode 100644 index 0000000000..71acc9bb3c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/MediaPlayer.kt @@ -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 + + /** + * 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 = _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) + } + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/SimplePlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/SimplePlayer.kt new file mode 100644 index 0000000000..aeaa7bd69f --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mediaplayer/SimplePlayer.kt @@ -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() +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index dccc020e49..22d1d9cacd 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/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 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 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( eventSink: (TimelineEvents) -> Unit, modifier: Modifier = Modifier ) { + val presenterFactories = LocalTimelineItemPresenterFactories.current when (content) { is TimelineItemEncryptedContent -> TimelineItemEncryptedView( content = content, @@ -100,5 +106,14 @@ fun TimelineItemEventContentView( eventSink = eventSink, modifier = modifier, ) + is TimelineItemVoiceContent -> { + val presenter: Presenter = presenterFactories.rememberPresenter(content) + TimelineItemVoiceView( + state = presenter.present(), + content = content, + extraPadding = extraPadding, + modifier = modifier + ) + } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt new file mode 100644 index 0000000000..e30993fe5b --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -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 { + private val voiceMessageStateProvider = VoiceMessageStateProvider() + private val timelineItemVoiceContentProvider = TimelineItemVoiceContentProvider() + override val values: Sequence + 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, + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt index 55e889f496..b5a3c9545e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt +++ b/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( 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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index f39c1e0989..4f78c38393 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/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 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 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( 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( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt index 844942002a..51ab200c48 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt +++ b/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 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 { is TimelineItemAudioContent, is TimelineItemLocationContent, is TimelineItemPollContent, + is TimelineItemVoiceContent, TimelineItemRedactedContent, TimelineItemUnknownContent -> false is TimelineItemProfileChangeContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt index ef31d6249c..eda9bcbdb4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt +++ b/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 = */ 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 = is TimelineItemImageContent, is TimelineItemLocationContent, is TimelineItemPollContent, + is TimelineItemVoiceContent, is TimelineItemVideoContent -> true is TimelineItemStateContent, is TimelineItemRedactedContent, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt new file mode 100644 index 0000000000..ec4647b7bd --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContent.kt @@ -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, +) : TimelineItemEventContent { + override val type: String = "TimelineItemAudioContent" +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt new file mode 100644 index 0000000000..8c9ac1f21c --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt @@ -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 { + override val values: Sequence + 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 = 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(), +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt index 03a2063b36..6b3ed3d65e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt +++ b/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 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( 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) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageCache.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageCache.kt new file mode 100644 index 0000000000..8ced58132e --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageCache.kt @@ -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 "/". + * @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]) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt new file mode 100644 index 0000000000..3f2aa70f5d --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt @@ -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 +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt new file mode 100644 index 0000000000..a2d4f0b655 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt @@ -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 + + /** + * 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 = 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() + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt new file mode 100644 index 0000000000..f94176b601 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt @@ -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 { + + @AssistedFactory + fun interface Factory : TimelineItemPresenterFactory { + 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.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) }, + ) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt new file mode 100644 index 0000000000..093d5336fd --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt @@ -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, + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt new file mode 100644 index 0000000000..ca40ec8cd7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt @@ -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 { + override val values: Sequence + 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 = {} + ), + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/WaveformProgressIndicator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/WaveformProgressIndicator.kt new file mode 100644 index 0000000000..94731ef0c8 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/WaveformProgressIndicator.kt @@ -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, + 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.scaleAmplitudes(): List { + val maxAmplitude = if (isEmpty()) 1 else maxOf { it } + val scalingFactor = 128 / maxAmplitude.toFloat() + return map { (it * scalingFactor).toInt() } +} diff --git a/features/messages/impl/src/main/res/drawable/pause.xml b/features/messages/impl/src/main/res/drawable/pause.xml new file mode 100644 index 0000000000..875a9ce403 --- /dev/null +++ b/features/messages/impl/src/main/res/drawable/pause.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/messages/impl/src/main/res/drawable/play.xml b/features/messages/impl/src/main/res/drawable/play.xml new file mode 100644 index 0000000000..4e9df7b71d --- /dev/null +++ b/features/messages/impl/src/main/res/drawable/play.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/messages/impl/src/main/res/drawable/retry.xml b/features/messages/impl/src/main/res/drawable/retry.xml new file mode 100644 index 0000000000..c3fda8bca6 --- /dev/null +++ b/features/messages/impl/src/main/res/drawable/retry.xml @@ -0,0 +1,9 @@ + + + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt index f94e79f0cb..460979f289 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt +++ b/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 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 { 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 { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt index 0d4dc98340..18fa0e390e 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt @@ -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()), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/mediaplayer/FakeMediaPlayer.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/mediaplayer/FakeMediaPlayer.kt new file mode 100644 index 0000000000..a9f0349552 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/mediaplayer/FakeMediaPlayer.kt @@ -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 = _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 + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/FakeVoiceMessageCache.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/FakeVoiceMessageCache.kt new file mode 100644 index 0000000000..ee61a690e6 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/FakeVoiceMessageCache.kt @@ -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 + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessageCacheTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessageCacheTest.kt new file mode 100644 index 0000000000..3c7a6468a0 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessageCacheTest.kt @@ -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() + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt new file mode 100644 index 0000000000..c748679421 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt @@ -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, +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 831fae272e..a9499d81ee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" diff --git a/settings.gradle.kts b/settings.gradle.kts index 9ac2c96dde..3da4e5efe9 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -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 diff --git a/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 b/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 new file mode 100644 index 0000000000..7756f762bb --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1baaa9c4b468aee17da82b1fcafc2a27ad92c4f9bc46122a4cf34a45bbee9e04 +size 6284 diff --git a/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 b/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 new file mode 100644 index 0000000000..a0e5107456 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8121dbdd25121b7584a3986030d1ca3c842cdf68f6f2650192ea4407eb9f0cf9 +size 6317 diff --git a/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 b/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 new file mode 100644 index 0000000000..20fa0bfcb7 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b862d5abc71d58664b4ec0e2f4f442f10afec104eb0d6c26cbd937f67a06107 +size 5987 diff --git a/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 b/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 new file mode 100644 index 0000000000..aa265d4839 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9fa37a41e254e6eeb79892992085a37c80961ef6fe2c0420fbad5522ec406d4 +size 6207 diff --git a/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 b/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 new file mode 100644 index 0000000000..59b3773b56 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:251c1e0b256fa73d61af9e3e9893ce5f11d3b78b4e539f66bf44a25073608760 +size 6245 diff --git a/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 b/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 new file mode 100644 index 0000000000..7c501314be --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:42d1dfd1198f52e615ad13ed4ef42e08a06e792b659cde7625b6bc8e3b599cdb +size 5930 diff --git a/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 b/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 new file mode 100644 index 0000000000..4fc94a511d --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a3f80ebbc586c13eac889707a5ffbb5ce0a633557e9893a9d09912872627bd5 +size 9423 diff --git a/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 b/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 new file mode 100644 index 0000000000..0c393c6b95 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be4c1a805efff2c4c09100bef211ffecf7f0d75ff305084af4c2a4b0496bb9cb +size 9212 diff --git a/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 b/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 new file mode 100644 index 0000000000..2d34a3f1ed --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e387949352779b84674d3dc23760305b49c197342e3f90fa8ca8576debfa3201 +size 25382 diff --git a/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 b/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 new file mode 100644 index 0000000000..d2a061dd54 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55b5ec0b3483805b0898a39fcc6d9d20adcebdc03a1926eaa52c5f34f6484be6 +size 25026 diff --git a/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 b/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 new file mode 100644 index 0000000000..a0d7f77db5 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c576c7288e434193189c23f75f92b9569c65c878c4dd9e9dc9b3a44af43f792 +size 5555 diff --git a/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 b/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 new file mode 100644 index 0000000000..fb9b2bce5e --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e742874a48ffbfa5322d01537fbd49cacf7e42b98b87eb76bf1459a14fb82ac2 +size 6353 diff --git a/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 b/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 new file mode 100644 index 0000000000..1fbf4be54d --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b14b7052c2110b2f692472ab8b33631486ab863037106d59e39480203c6432b1 +size 5375 diff --git a/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 b/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 new file mode 100644 index 0000000000..a1eaf816e8 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4108d9a249b5bb1c0c88dfe54f9df0f5f7b3a5777d6fd5e00d5fd5e41608cf09 +size 21917 diff --git a/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 b/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 new file mode 100644 index 0000000000..565ea280dc --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c41c75f17e291e0be64551202fac5147a0174da7634c1e3645842402f61ae4d9 +size 5111 diff --git a/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 b/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 new file mode 100644 index 0000000000..c71367390f --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4ec3da0af91751e2d2e949da2f8cb94f4805e7747ba5403afd736a393d58447f +size 7145 diff --git a/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 b/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 new file mode 100644 index 0000000000..2161c84892 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d8c35c24f0b725c56fc54eb025a4c42c69cf3acbad37cc550b0b950812cb20b +size 10103 diff --git a/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 b/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 new file mode 100644 index 0000000000..bbb366fac5 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43891e66bae441f53f576c0affae0f36c98399f9bfe1d034f0db6ae218da8e47 +size 8233 diff --git a/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 b/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 new file mode 100644 index 0000000000..cf3f824fd9 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7240b9666592f3627bb2e944cc83ab31eface8139c5c98dcc6c1ad9e7ca9b8b6 +size 11285 diff --git a/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 b/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 new file mode 100644 index 0000000000..f9d723a20f --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5b1c4557ffd800c0eca4e41e04e1aea9c6cd0ef94b70f1704352fe8070d8fff1 +size 7507 diff --git a/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 b/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 new file mode 100644 index 0000000000..fc801539d6 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6859208aff5071a73cd5c8f9f5fe4437abd793fb564bb4dfc4a3a590a8262a1e +size 7946 diff --git a/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 b/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 new file mode 100644 index 0000000000..f3c121e692 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1587014aa4cef2ade84e55ab0a9209d0f10b8706164d8e8b28933554dce24d59 +size 14386 diff --git a/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 b/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 new file mode 100644 index 0000000000..143d985235 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac00bcc3150b97a628a03996ce273f68bf5c8ac9db014a7039e3510b4b307726 +size 5675 diff --git a/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 b/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 new file mode 100644 index 0000000000..3312595f8a --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4c0a959f003d8eedf2a4b9284f63ea77a608e272d0dd8e564bbbbf3686c88758 +size 11572 diff --git a/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 b/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 new file mode 100644 index 0000000000..5a50bf3ace --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3a3ffdc5062cea8c3226df50b0876a3ca02045a19e05974b95e5ac83b813f39 +size 11502 diff --git a/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 b/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 new file mode 100644 index 0000000000..ee412cae4a --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f5585d6b47d201c1d3d8b1e330b6ebe2bc23811a72f4454add2f563cdeb41f6 +size 6193 diff --git a/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 b/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 new file mode 100644 index 0000000000..c991c0d165 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05bb4680a0f8afb75b8f32292b54ccc14aac2e868bc122e110c07d4dc2b30915 +size 8596 diff --git a/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 b/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 new file mode 100644 index 0000000000..5205bb76ba --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e3588c936172c143fb195a9113d293858c8bd3678b24a88ca64dd9f3c2cb7a1 +size 8554 diff --git a/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 b/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 new file mode 100644 index 0000000000..68248c360a --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3cb4d5f28b52e87ce74ca667a330b3b706d1373e6a23c9a9f6a82be9a0460adc +size 5494 diff --git a/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 b/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 new file mode 100644 index 0000000000..5f22f73c3d --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2c169da272ac92d2f9ada1eb90a3c644c54e6788141b4d0ee07c49a59972f567 +size 6455 diff --git a/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 b/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 new file mode 100644 index 0000000000..b71a68a418 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8803bbcece24e3c1dd3eed4055fcb8b8aa1a3d3d06feb5c37813499808c7faf0 +size 6481 diff --git a/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 b/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 new file mode 100644 index 0000000000..fc7fbf6896 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f17122c670bd4e9e8f419d40820d29ac75d3f7f0e5d6d1b4e819ddacfe40057 +size 5922 diff --git a/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 b/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 new file mode 100644 index 0000000000..10306cd011 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a6c94b36f43c47dea5d2b06b85ec2008b445ee54bffb45be812c3749e7c8fff +size 5527 diff --git a/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 b/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 new file mode 100644 index 0000000000..060232bac3 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d8e5ff9c6ba12aee23ad547b71666096f5a184ad94877135587d77e1ed263c99 +size 6240 diff --git a/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 b/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 new file mode 100644 index 0000000000..d0eeef63a1 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f91e0c0c857b7e97a31c01a12947843eca7af664d23dec336e30d386575cd955 +size 5345 diff --git a/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 b/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 new file mode 100644 index 0000000000..dd72a20d7b --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a3f45d7dffec016578932cfd230bd57a649b2e725943f81c4d157a958826abe +size 20718 diff --git a/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 b/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 new file mode 100644 index 0000000000..857c4a7154 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98992d574b00fd9b168541d7826d2290ffb5598ed31aa540ef86eb05b8cad6a0 +size 5115 diff --git a/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 b/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 new file mode 100644 index 0000000000..b1064f1c42 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12cb95c208d0c536200e75e72476f47bd584a039f47a11be86de607cfdca24ff +size 6836 diff --git a/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 b/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 new file mode 100644 index 0000000000..0c99ce1967 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22011955b67135850138578a5d0c0211541ec1cb2d422487d149c05d2da5537b +size 9513 diff --git a/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 b/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 new file mode 100644 index 0000000000..90e662cf04 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43e01f8600b71c4b7d3437a89331cfa2723049b10500b74902b17e8bae82ecaa +size 7984 diff --git a/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 b/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 new file mode 100644 index 0000000000..2beaddefc7 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9e94e9e5b42dc50ef2958872a2307bb5411e25ab8bf3e696fcc1948d04a602a +size 10588 diff --git a/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 b/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 new file mode 100644 index 0000000000..8a641258e9 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e3e6f73a4ca3e5630d498541e6ba4b62505652e35f8368bda9dbc5cce618456 +size 7367 diff --git a/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 b/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 new file mode 100644 index 0000000000..dcfa4bfadc --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:517d6b30065d903215ab57446f3639c3876d85b85bff4679ffae4c2132775dda +size 7567 diff --git a/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 b/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 new file mode 100644 index 0000000000..136afbb481 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:945db7d76ce34a2285e8d083f8c9651204af59aa800ffda4587f854676f717b7 +size 13529 diff --git a/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 b/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 new file mode 100644 index 0000000000..291dd9e57f --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31d2f62b114e1b41d3c7683b2c08e7d70931139e87cf76341d03fb488add4838 +size 5662 diff --git a/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 b/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 new file mode 100644 index 0000000000..4dd2a2088c --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51155f13d3795198a301788288351c386e4d509e6ebd3ed103e743762e54a426 +size 11208 diff --git a/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 b/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 new file mode 100644 index 0000000000..5955c38495 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6199ff6234486bfe7ed87438298086e09e51dcbe4b30a71fa494764f5b3db14c +size 11142 diff --git a/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 b/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 new file mode 100644 index 0000000000..4951caddd8 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1dd0872ea0786c5b9c037f05aef2c43d278e450558cb2f2245d76a8051f4836 +size 6089 diff --git a/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 b/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 new file mode 100644 index 0000000000..37bca53ea5 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:63953e0cca146083474b69eef7ac861391e111c319ca027e8222dc28cdab7032 +size 8075 diff --git a/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 b/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 new file mode 100644 index 0000000000..cd99dfc019 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d5d4c973178bf5793e1231abdc1837aecd16bdb227d0b5128a36db8e1d36290c +size 8009 diff --git a/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 b/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 new file mode 100644 index 0000000000..d765ec297d --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa2431a475eecdf0980bd7bf237e30d8b2e1b98170f42474d8297286386342c0 +size 5433 diff --git a/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 b/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 new file mode 100644 index 0000000000..0c5e985c08 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0179e7d8b68b2bb130e0c6c8db6adde979c724d293918822e6807e9eef627135 +size 6300 diff --git a/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 b/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 new file mode 100644 index 0000000000..29e881112d --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:506c86795d7940d0cddef77d73eb225b31c9204fc19b81078d95eab71f6d9499 +size 6301 diff --git a/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 b/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 new file mode 100644 index 0000000000..dce38442ab --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc0d2daf9d4bda4005f01c604bdc6688673e9a50d5e92ca1668c481327529282 +size 5831 diff --git a/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 b/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 new file mode 100644 index 0000000000..56702cb5b9 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbe739da995dca5700bcbb6a944c611e7c0b427d39b5e085345d8a7e93759e7c +size 25201 diff --git a/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 b/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 new file mode 100644 index 0000000000..a61dfbb6e3 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09864a80f5eaf0874dfdde8698b3fb6ef8ad95059f2d955e822f80009f49849f +size 24966 diff --git a/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 b/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 new file mode 100644 index 0000000000..273762d670 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6293aceaed02886f3eb1d344c70f93772377d0ae09e69e6a382b33f577a26cd8 +size 14765 diff --git a/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 b/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 new file mode 100644 index 0000000000..273762d670 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6293aceaed02886f3eb1d344c70f93772377d0ae09e69e6a382b33f577a26cd8 +size 14765 diff --git a/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 b/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 new file mode 100644 index 0000000000..1276837323 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bc02697864bb7289bccfdfc137a7768b763c378e2ca057103058ae0d8c94f98 +size 13711 diff --git a/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 b/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 new file mode 100644 index 0000000000..1276837323 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bc02697864bb7289bccfdfc137a7768b763c378e2ca057103058ae0d8c94f98 +size 13711 diff --git a/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 b/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 new file mode 100644 index 0000000000..57ff1f481f --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dc16dd2b27cf281c39932013a904a72bb6b417a07105d3d51b96f4b49a642e25 +size 14865 diff --git a/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 b/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 new file mode 100644 index 0000000000..a565274d3c --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b93e0fc28c37a90991ff9778f40fc8e9b07da593d21c6bfc73451006ef6d067c +size 14415 diff --git a/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 b/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 new file mode 100644 index 0000000000..09788f09a3 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3cea0fa661bd887a3ab291489603248745dac2db8ee0822079b1ecce486f0aa8 +size 5972 diff --git a/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 b/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 new file mode 100644 index 0000000000..4693d26997 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b001719d7d3461e6f03205e7bb368e50a2f5479bf82db7fab1b2e40c51c2a6b8 +size 7644 diff --git a/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 b/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 new file mode 100644 index 0000000000..6edbe7b546 --- /dev/null +++ b/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 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:78f3db8332e540f8a55d65dbc95e0b7ae446d4659f9f3b88ea4545b0dda89646 +size 5861 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineItemDaySeparatorView-N-46_47_null_1,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineItemDaySeparatorView-N-46_47_null_1,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..0258c5a7cd --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineItemDaySeparatorView-N-46_47_null_1,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0533315f6cc6f886ed9542564b43fe6bd43760f1c78b37f9c894d44b624040d9 +size 7306 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineLoadingMoreIndicator-D-47_47_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineLoadingMoreIndicator-D-47_47_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..9b3c889d05 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineLoadingMoreIndicator-D-47_47_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c464ff6c892364dc096a8edba88074d891b9baa91f4ba50197c7a500d76c0fa7 +size 6247 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineLoadingMoreIndicator-N-47_48_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineLoadingMoreIndicator-N-47_48_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..e443bf7f5d --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.virtual_null_TimelineLoadingMoreIndicator-N-47_48_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:03e53795df5b0078a7883a4ed181e2a022aeec9f095eaddeed213e3de9b16069 +size 6200 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.debug_null_EventDebugInfoView-D-48_48_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.debug_null_EventDebugInfoView-D-48_48_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..84ff0e89da --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.debug_null_EventDebugInfoView-D-48_48_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7037bab35eb36d248b7ed2605a67c003c9d8f74e315d2b5c9097415c50c0be2d +size 34912 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.debug_null_EventDebugInfoView-N-48_49_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.debug_null_EventDebugInfoView-N-48_49_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..87c41b8968 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.debug_null_EventDebugInfoView-N-48_49_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:da19273bbf3dfe1bad06e63ab05b30a58885386f7c2017912ed15f3a1856fdba +size 32843 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.voicemessages.timeline_null_WaveformProgressIndicator-D-49_49_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.voicemessages.timeline_null_WaveformProgressIndicator-D-49_49_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..665c8811ac --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.voicemessages.timeline_null_WaveformProgressIndicator-D-49_49_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bb0d3bfcfd75cbd75fd9270ff1dc27090e5dbac79ca8db8a46d91a4c12bc966b +size 4457 diff --git a/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.voicemessages.timeline_null_WaveformProgressIndicator-N-49_50_null,NEXUS_5,1.0,en].png b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.voicemessages.timeline_null_WaveformProgressIndicator-N-49_50_null,NEXUS_5,1.0,en].png new file mode 100644 index 0000000000..fae8a6fca3 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.voicemessages.timeline_null_WaveformProgressIndicator-N-49_50_null,NEXUS_5,1.0,en].png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8c89ac73df77c2bccb0c2aa80cee1420f78e7d07f0eda89a90bffef55e8cf753 +size 4464