Display duration of recorded voice message (#1733)

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
jonnyandrew
2023-11-03 12:59:36 +00:00
committed by GitHub
parent 14dc8e1059
commit ddc1e1d0cc
12 changed files with 119 additions and 40 deletions
@@ -36,7 +36,7 @@ class VoiceMessageComposerPlayer @Inject constructor(
val state: Flow<State> = mediaPlayer.state.map { state -> val state: Flow<State> = mediaPlayer.state.map { state ->
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) { if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
return@map State.NotPlaying return@map State.NotLoaded
} }
State( State(
@@ -89,13 +89,15 @@ class VoiceMessageComposerPlayer @Inject constructor(
val duration: Long, val duration: Long,
) { ) {
companion object { companion object {
val NotPlaying = State( val NotLoaded = State(
isPlaying = false, isPlaying = false,
currentPosition = 0L, currentPosition = 0L,
duration = 0L, duration = 0L,
) )
} }
val isLoaded get() = this != NotLoaded
/** /**
* The progress of this player between 0 and 1. * The progress of this player between 0 and 1.
*/ */
@@ -42,14 +42,15 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voicerecorder.api.VoiceRecorder import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@SingleIn(RoomScope::class) @SingleIn(RoomScope::class)
@@ -72,8 +73,8 @@ class VoiceMessageComposerPresenter @Inject constructor(
val permissionState = permissionsPresenter.present() val permissionState = permissionsPresenter.present()
var isSending by remember { mutableStateOf(false) } var isSending by remember { mutableStateOf(false) }
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotPlaying) val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotLoaded)
val isPlaying by remember(playerState.isPlaying) { derivedStateOf { playerState.isPlaying } } val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } } val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
val onLifecycleEvent = { event: Lifecycle.Event -> val onLifecycleEvent = { event: Lifecycle.Event ->
@@ -190,9 +191,10 @@ class VoiceMessageComposerPresenter @Inject constructor(
) )
is VoiceRecorderState.Finished -> VoiceMessageState.Preview( is VoiceRecorderState.Finished -> VoiceMessageState.Preview(
isSending = isSending, isSending = isSending,
isPlaying = isPlaying, isPlaying = playerState.isPlaying,
showCursor = playerState.isLoaded && !isSending,
playbackProgress = playerState.progress, playbackProgress = playerState.progress,
time = playerState.currentPosition.milliseconds, time = playerTime,
waveform = waveform, waveform = waveform,
) )
else -> VoiceMessageState.Idle else -> VoiceMessageState.Idle
@@ -259,3 +261,20 @@ private fun VoiceRecorderState.finishedWaveform(): ImmutableList<Float> =
?.waveform ?.waveform
.orEmpty() .orEmpty()
.toImmutableList() .toImmutableList()
/**
* The time to display depending on the player state.
*
* Either the current position or total duration.
*/
private fun displayTime(
playerState: VoiceMessageComposerPlayer.State,
recording: VoiceRecorderState
): Duration = when {
playerState.isLoaded ->
playerState.currentPosition.milliseconds
recording is VoiceRecorderState.Finished ->
recording.duration
else ->
0.milliseconds
}
@@ -179,7 +179,7 @@ class VoiceMessageComposerPresenterTest {
} }
// Nothing should happen // Nothing should happen
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(RECORDING_DURATION, RECORDING_STATE.levels)) assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0) voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0)
testPauseAndDestroy(finalState) testPauseAndDestroy(finalState)
@@ -196,7 +196,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
val finalState = awaitItem().also { val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = true, playbackProgress = 0.1f, time = RECORDING_DURATION)) assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
} }
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
@@ -215,7 +215,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
val finalState = awaitItem().also { val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f, time = RECORDING_DURATION)) assertThat(it.voiceMessageState).isEqualTo(aPausedState())
} }
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
@@ -252,7 +252,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
awaitItem().apply { awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f, time = RECORDING_DURATION)) assertThat(voiceMessageState).isEqualTo(aPausedState())
} }
val finalState = awaitItem() val finalState = awaitItem()
@@ -272,7 +272,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true)) assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@@ -322,11 +322,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo( assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
aPreviewState(
isSending = true, isPlaying = false, playbackProgress = 0.1f, time = RECORDING_DURATION
)
)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@@ -349,7 +345,7 @@ class VoiceMessageComposerPresenterTest {
eventSink(VoiceMessageComposerEvents.SendVoiceMessage) eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
eventSink(VoiceMessageComposerEvents.SendVoiceMessage) eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
} }
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true)) assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@@ -398,7 +394,7 @@ class VoiceMessageComposerPresenterTest {
val previewState = awaitItem() val previewState = awaitItem()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true)) assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
ensureAllEventsConsumed() ensureAllEventsConsumed()
assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState()) assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
@@ -573,7 +569,7 @@ class VoiceMessageComposerPresenterTest {
is VoiceMessageState.Preview -> when (state.isPlaying) { is VoiceMessageState.Preview -> when (state.isPlaying) {
// If the preview was playing, it pauses // If the preview was playing, it pauses
true -> awaitItem().apply { true -> awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.1f, time = RECORDING_DURATION)) assertThat(voiceMessageState).isEqualTo(aPausedState())
} }
false -> mostRecentState false -> mostRecentState
} }
@@ -627,15 +623,37 @@ class VoiceMessageComposerPresenterTest {
isPlaying: Boolean = false, isPlaying: Boolean = false,
playbackProgress: Float = 0f, playbackProgress: Float = 0f,
isSending: Boolean = false, isSending: Boolean = false,
time: Duration = 0.seconds, time: Duration = RECORDING_DURATION,
showCursor: Boolean = false,
waveform: List<Float> = voiceRecorder.waveform, waveform: List<Float> = voiceRecorder.waveform,
) = VoiceMessageState.Preview( ) = VoiceMessageState.Preview(
isPlaying = isPlaying, isPlaying = isPlaying,
playbackProgress = playbackProgress, playbackProgress = playbackProgress,
isSending = isSending, isSending = isSending,
time = time, time = time,
showCursor = showCursor,
waveform = waveform.toImmutableList(), waveform = waveform.toImmutableList(),
) )
private fun aPlayingState() =
aPreviewState(
isPlaying = true,
playbackProgress = 0.1f,
showCursor = true,
time = RECORDING_DURATION,
)
private fun aPausedState() =
aPlayingState()
.copy(isPlaying = false)
private fun VoiceMessageState.Preview.toSendingState() =
copy(
isPlaying = false,
isSending = true,
showCursor = false,
time = RECORDING_DURATION,
)
} }
private fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE) private fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE)
@@ -207,6 +207,7 @@ fun TextComposer(
VoiceMessagePreview( VoiceMessagePreview(
isInteractive = !voiceMessageState.isSending, isInteractive = !voiceMessageState.isSending,
isPlaying = voiceMessageState.isPlaying, isPlaying = voiceMessageState.isPlaying,
showCursor = voiceMessageState.showCursor,
waveform = voiceMessageState.waveform, waveform = voiceMessageState.waveform,
playbackProgress = voiceMessageState.playbackProgress, playbackProgress = voiceMessageState.playbackProgress,
time = voiceMessageState.time, time = voiceMessageState.time,
@@ -816,6 +817,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Preview( voiceMessageState = VoiceMessageState.Preview(
isSending = false, isSending = false,
isPlaying = false, isPlaying = false,
showCursor = false,
waveform = createFakeWaveform(), waveform = createFakeWaveform(),
time = 0.seconds, time = 0.seconds,
playbackProgress = 0.0f playbackProgress = 0.0f
@@ -826,6 +828,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Preview( voiceMessageState = VoiceMessageState.Preview(
isSending = false, isSending = false,
isPlaying = true, isPlaying = true,
showCursor = true,
waveform = createFakeWaveform(), waveform = createFakeWaveform(),
time = 3.seconds, time = 3.seconds,
playbackProgress = 0.2f playbackProgress = 0.2f
@@ -836,6 +839,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
voiceMessageState = VoiceMessageState.Preview( voiceMessageState = VoiceMessageState.Preview(
isSending = true, isSending = true,
isPlaying = false, isPlaying = false,
showCursor = false,
waveform = createFakeWaveform(), waveform = createFakeWaveform(),
time = 61.seconds, time = 61.seconds,
playbackProgress = 0.0f playbackProgress = 0.0f
@@ -55,6 +55,7 @@ import kotlin.time.Duration.Companion.seconds
internal fun VoiceMessagePreview( internal fun VoiceMessagePreview(
isInteractive: Boolean, isInteractive: Boolean,
isPlaying: Boolean, isPlaying: Boolean,
showCursor: Boolean,
waveform: ImmutableList<Float>, waveform: ImmutableList<Float>,
time: Duration, time: Duration,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -105,7 +106,7 @@ internal fun VoiceMessagePreview(
.weight(1f) .weight(1f)
.height(26.dp), .height(26.dp),
playbackProgress = playbackProgress, playbackProgress = playbackProgress,
showCursor = isInteractive, showCursor = showCursor,
waveform = waveform, waveform = waveform,
seekEnabled = false, // TODO enable seeking seekEnabled = false, // TODO enable seeking
onSeek = onSeek, onSeek = onSeek,
@@ -162,8 +163,29 @@ internal fun VoiceMessagePreviewPreview() = ElementPreview {
Column( Column(
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
VoiceMessagePreview(isInteractive = true, isPlaying = true, time = 2.seconds, playbackProgress = 0.2f, waveform = createFakeWaveform()) VoiceMessagePreview(
VoiceMessagePreview(isInteractive = true, isPlaying = false, time = 0.seconds, playbackProgress = 0.0f, waveform = createFakeWaveform()) isInteractive = true,
VoiceMessagePreview(isInteractive = false, isPlaying = false, time = 789.seconds, playbackProgress = 0.0f, waveform = createFakeWaveform()) isPlaying = true,
time = 2.seconds,
playbackProgress = 0.2f,
showCursor = true,
waveform = createFakeWaveform()
)
VoiceMessagePreview(
isInteractive = true,
isPlaying = false,
time = 0.seconds,
playbackProgress = 0.0f,
showCursor = true,
waveform = createFakeWaveform()
)
VoiceMessagePreview(
isInteractive = false,
isPlaying = false,
time = 789.seconds,
playbackProgress = 0.0f,
showCursor = false,
waveform = createFakeWaveform()
)
} }
} }
@@ -25,6 +25,7 @@ sealed class VoiceMessageState {
data class Preview( data class Preview(
val isSending: Boolean, val isSending: Boolean,
val isPlaying: Boolean, val isPlaying: Boolean,
val showCursor: Boolean,
val playbackProgress: Float, val playbackProgress: Float,
val time: Duration, val time: Duration,
val waveform: ImmutableList<Float>, val waveform: ImmutableList<Float>,
@@ -39,10 +39,12 @@ sealed class VoiceRecorderState {
* @property file The recorded file. * @property file The recorded file.
* @property mimeType The mime type of the file. * @property mimeType The mime type of the file.
* @property waveform The waveform of the recording. * @property waveform The waveform of the recording.
* @property duration The total time spent recording.
*/ */
data class Finished( data class Finished(
val file: File, val file: File,
val mimeType: String, val mimeType: String,
val waveform: List<Float>, val waveform: List<Float>,
val duration: Duration,
) : VoiceRecorderState() ) : VoiceRecorderState()
} }
@@ -45,6 +45,7 @@ import timber.log.Timber
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.TimeSource import kotlin.time.TimeSource
@@ -93,7 +94,7 @@ class VoiceRecorderImpl @Inject constructor(
val elapsedTime = startedAt.elapsedNow() val elapsedTime = startedAt.elapsedNow()
if (elapsedTime >= 30.minutes) { if (elapsedTime > 30.minutes) {
Timber.w("Voice message time limit reached") Timber.w("Voice message time limit reached")
stopRecord(false) stopRecord(false)
return@record return@record
@@ -145,12 +146,16 @@ class VoiceRecorderImpl @Inject constructor(
_state.emit( _state.emit(
when (val file = outputFile) { when (val file = outputFile) {
null -> VoiceRecorderState.Idle null -> VoiceRecorderState.Idle
else -> VoiceRecorderState.Finished( else -> {
val duration = (state.value as? VoiceRecorderState.Recording)?.elapsedTime
VoiceRecorderState.Finished(
file = file, file = file,
mimeType = fileConfig.mimeType, mimeType = fileConfig.mimeType,
waveform = levels.resample(100), waveform = levels.resample(100),
duration = duration ?: 0.milliseconds
) )
} }
}
) )
} }
} }
@@ -37,6 +37,7 @@ import kotlinx.coroutines.test.runTest
import org.junit.BeforeClass import org.junit.BeforeClass
import org.junit.Test import org.junit.Test
import java.io.File import java.io.File
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource import kotlin.time.TestTimeSource
@@ -76,34 +77,38 @@ class VoiceRecorderImplTest {
voiceRecorder.startRecord() voiceRecorder.startRecord()
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.minutes, listOf(1.0f))) assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.minutes, listOf(1.0f)))
timeSource += 29.minutes timeSource += 30.minutes
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(29.minutes, listOf())) assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(30.minutes, listOf()))
timeSource += 1.minutes timeSource += 1.milliseconds
assertThat(awaitItem()).isEqualTo( assertThat(awaitItem()).isEqualTo(
VoiceRecorderState.Finished( VoiceRecorderState.Finished(
file = File(FILE_PATH), file = File(FILE_PATH),
mimeType = "audio/ogg", mimeType = "audio/ogg",
waveform = List(100) { 1f }, waveform = List(100) { 1f },
duration = 30.minutes,
) )
) )
} }
} }
@Test @Test
fun `when stopped, it provides a file`() = runTest { fun `when stopped, it provides a file and duration`() = runTest {
val voiceRecorder = createVoiceRecorder() val voiceRecorder = createVoiceRecorder()
voiceRecorder.state.test { voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord() voiceRecorder.startRecord()
skipItems(3) skipItems(1)
timeSource += 5.seconds
skipItems(2)
voiceRecorder.stopRecord() voiceRecorder.stopRecord()
assertThat(awaitItem()).isEqualTo( assertThat(awaitItem()).isEqualTo(
VoiceRecorderState.Finished( VoiceRecorderState.Finished(
file = File(FILE_PATH), file = File(FILE_PATH),
mimeType = "audio/ogg", mimeType = "audio/ogg",
waveform = List(100) { 1f }, waveform = List(100) { 1f },
duration = 5.seconds,
) )
) )
assertThat(fakeFileSystem.files[File(FILE_PATH)]).isEqualTo(ENCODED_DATA) assertThat(fakeFileSystem.files[File(FILE_PATH)]).isEqualTo(ENCODED_DATA)
@@ -74,6 +74,7 @@ class FakeVoiceRecorder(
else -> VoiceRecorderState.Finished( else -> VoiceRecorderState.Finished(
file = curRecording!!, file = curRecording!!,
mimeType = "audio/ogg", mimeType = "audio/ogg",
duration = recordingDuration,
waveform = waveform, waveform = waveform,
) )
} }
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c692c6d70f847d37dce6c1cdbe630621cc6bdf37d48e24b4f95aa79d9422f15d oid sha256:991416dcd21ebf8e2d57baac7cb42506596a1a346dd7d643b09c84c473e053b7
size 28039 size 28016
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2f9b34504384622feed1348a00172d520123512c433973094634d159850a7653 oid sha256:a8b8c60473ce1b9e7451e763c960a9242bb83ca1d3769ee921db830ff6e57e7c
size 27124 size 27094