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 0e89080a50
commit 413ec4b5db
12 changed files with 119 additions and 40 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,11 +146,15 @@ 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 -> {
file = file, val duration = (state.value as? VoiceRecorderState.Recording)?.elapsedTime
mimeType = fileConfig.mimeType, VoiceRecorderState.Finished(
waveform = levels.resample(100), file = file,
) mimeType = fileConfig.mimeType,
waveform = levels.resample(100),
duration = duration ?: 0.milliseconds
)
}
} }
) )
} }

View file

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

View file

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

View file

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

View file

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