Add voice message recording duration indicator and limit (#1628)

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
jonnyandrew 2023-10-24 12:44:53 +01:00 committed by GitHub
parent bdc52332bb
commit 9046ac4c8a
22 changed files with 263 additions and 35 deletions

View file

@ -141,7 +141,10 @@ class VoiceMessageComposerPresenter @Inject constructor(
return VoiceMessageComposerState( return VoiceMessageComposerState(
voiceMessageState = when (val state = recorderState) { voiceMessageState = when (val state = recorderState) {
is VoiceRecorderState.Recording -> VoiceMessageState.Recording(level = state.level) is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
duration = state.elapsedTime,
level = state.level
)
is VoiceRecorderState.Finished -> if (isSending) { is VoiceRecorderState.Finished -> if (isSending) {
VoiceMessageState.Sending VoiceMessageState.Sending
} else { } else {

View file

@ -18,11 +18,12 @@ package io.element.android.features.messages.impl.voicemessages
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.textcomposer.model.VoiceMessageState import io.element.android.libraries.textcomposer.model.VoiceMessageState
import kotlin.time.Duration.Companion.seconds
internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> { internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider<VoiceMessageComposerState> {
override val values: Sequence<VoiceMessageComposerState> override val values: Sequence<VoiceMessageComposerState>
get() = sequenceOf( get() = sequenceOf(
aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(level = 0.5)), aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, level = 0.5)),
) )
} }

View file

@ -46,18 +46,26 @@ import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration.Companion.seconds
class VoiceMessageComposerPresenterTest { class VoiceMessageComposerPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
private val voiceRecorder = FakeVoiceRecorder() private val voiceRecorder = FakeVoiceRecorder(
recordingDuration = RECORDING_DURATION
)
private val analyticsService = FakeAnalyticsService() private val analyticsService = FakeAnalyticsService()
private val matrixRoom = FakeMatrixRoom() private val matrixRoom = FakeMatrixRoom()
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() } private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom) private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
companion object {
private val RECORDING_DURATION = 1.seconds
private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, 0.2)
}
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createVoiceMessageComposerPresenter()
@ -80,7 +88,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2)) assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
testPauseAndDestroy(finalState) testPauseAndDestroy(finalState)
} }
@ -270,7 +278,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2)) assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
testPauseAndDestroy(finalState) testPauseAndDestroy(finalState)
} }
@ -303,7 +311,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(0.2)) assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
testPauseAndDestroy(finalState) testPauseAndDestroy(finalState)
} }

View file

@ -32,6 +32,7 @@ dependencies {
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiUtils)
implementation(libs.matrix.richtexteditor) implementation(libs.matrix.richtexteditor)
api(libs.matrix.richtexteditor.compose) api(libs.matrix.richtexteditor.compose)

View file

@ -80,6 +80,7 @@ import io.element.android.wysiwyg.compose.RichTextEditor
import io.element.android.wysiwyg.compose.RichTextEditorState import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlin.time.Duration.Companion.seconds
@Composable @Composable
fun TextComposer( fun TextComposer(
@ -181,7 +182,7 @@ fun TextComposer(
VoiceMessageState.Sending -> VoiceMessageState.Sending ->
VoiceMessagePreview(isInteractive = false) VoiceMessagePreview(isInteractive = false)
is VoiceMessageState.Recording -> is VoiceMessageState.Recording ->
VoiceMessageRecording(voiceMessageState.level) VoiceMessageRecording(voiceMessageState.level, voiceMessageState.duration)
VoiceMessageState.Idle -> {} VoiceMessageState.Idle -> {}
} }
} }
@ -751,7 +752,7 @@ internal fun TextComposerVoicePreview() = ElementPreview {
enableVoiceMessages = true, enableVoiceMessages = true,
) )
PreviewColumn(items = persistentListOf({ PreviewColumn(items = persistentListOf({
VoicePreview(voiceMessageState = VoiceMessageState.Recording(0.5)) VoicePreview(voiceMessageState = VoiceMessageState.Recording(61.seconds, 0.5))
}, { }, {
VoicePreview(voiceMessageState = VoiceMessageState.Preview) VoicePreview(voiceMessageState = VoiceMessageState.Preview)
}, { }, {

View file

@ -36,10 +36,14 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.utils.time.formatShort
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Composable @Composable
internal fun VoiceMessageRecording( internal fun VoiceMessageRecording(
level: Double, level: Double,
duration: Duration,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@ -53,16 +57,13 @@ internal fun VoiceMessageRecording(
.heightIn(26.dp), .heightIn(26.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Box( RedRecordingDot()
modifier = Modifier
.size(8.dp)
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
)
Spacer(Modifier.size(8.dp)) Spacer(Modifier.size(8.dp))
// TODO Replace with timer UI // Timer
Text( Text(
text = "Recording...", // Not localized because it is a placeholder text = duration.formatShort(),
color = ElementTheme.colors.textSecondary, color = ElementTheme.colors.textSecondary,
style = ElementTheme.typography.fontBodySmMedium style = ElementTheme.typography.fontBodySmMedium
) )
@ -95,8 +96,17 @@ private fun DebugAudioLevel(
} }
} }
@Composable
private fun RedRecordingDot(
modifier: Modifier = Modifier,
) = Box(
modifier = modifier
.size(8.dp)
.background(color = ElementTheme.colors.textCriticalPrimary, shape = CircleShape)
)
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun VoiceMessageRecordingPreview() = ElementPreview { internal fun VoiceMessageRecordingPreview() = ElementPreview {
VoiceMessageRecording(0.5) VoiceMessageRecording(0.5, 0.seconds)
} }

View file

@ -16,12 +16,15 @@
package io.element.android.libraries.textcomposer.model package io.element.android.libraries.textcomposer.model
import kotlin.time.Duration
sealed class VoiceMessageState { sealed class VoiceMessageState {
data object Idle: VoiceMessageState() data object Idle: VoiceMessageState()
data object Preview: VoiceMessageState() data object Preview: VoiceMessageState()
data object Sending: VoiceMessageState() data object Sending: VoiceMessageState()
data class Recording( data class Recording(
val duration: Duration,
val level: Double, val level: Double,
): VoiceMessageState() ): VoiceMessageState()
} }

View file

@ -0,0 +1,28 @@
/*
* 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.libraries.ui.utils"
dependencies {
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)
}
}

View file

@ -0,0 +1,41 @@
/*
* 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.libraries.ui.utils.time
import kotlin.time.Duration
/**
* Format a duration as minutes:seconds.
*
* For example,
* - 0 seconds will be formatted as "0:00".
* - 65 seconds will be formatted as "1:05".
* - 2 hours will be formatted as "120:00".
* - negative 10 seconds will be formatted as "-0:10".
*
* @return the formatted duration.
*/
fun Duration.formatShort(): String {
// Format as minutes:seconds
val seconds = (absoluteValue.inWholeSeconds % 60)
.toString()
.padStart(2, '0')
val sign = isNegative().let { if (it) "-" else "" }
return "$sign${absoluteValue.inWholeMinutes}:$seconds"
}

View file

@ -0,0 +1,52 @@
/*
* 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.libraries.ui.utils.time
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import kotlin.time.Duration.Companion.seconds
@RunWith(value = Parameterized::class)
class DurationFormatTest(
private val seconds: Double,
private val output: String,
) {
companion object {
@Parameterized.Parameters(name = "{index}: format({0})={1}")
@JvmStatic
fun data(): Iterable<Array<Any>> {
return arrayListOf(
arrayOf<Any>(0, "0:00"),
arrayOf<Any>(1, "0:01"),
arrayOf<Any>(10, "0:10"),
arrayOf<Any>(59.9, "0:59"),
arrayOf<Any>(60, "1:00"),
arrayOf<Any>(61, "1:01"),
arrayOf<Any>(60 * 60, "60:00"),
arrayOf<Any>(-60, "-1:00"),
arrayOf<Any>(-1, "-0:01"),
).toList()
}
}
@Test
fun formatShort() {
assertEquals(output, seconds.seconds.formatShort())
}
}

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.voicerecorder.api package io.element.android.libraries.voicerecorder.api
import java.io.File import java.io.File
import kotlin.time.Duration
sealed class VoiceRecorderState { sealed class VoiceRecorderState {
/** /**
@ -27,9 +28,10 @@ sealed class VoiceRecorderState {
/** /**
* The recorder is currently recording. * The recorder is currently recording.
* *
* @property elapsedTime The elapsed time since the recording started.
* @property level The current audio level of the recording as a fraction of 1. * @property level The current audio level of the recording as a fraction of 1.
*/ */
data class Recording(val level: Double) : VoiceRecorderState() data class Recording(val elapsedTime: Duration, val level: Double) : VoiceRecorderState()
/** /**
* The recorder has finished recording. * The recorder has finished recording.

View file

@ -37,15 +37,19 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import timber.log.Timber 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.minutes
import kotlin.time.TimeSource
@SingleIn(RoomScope::class) @SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class) @ContributesBinding(RoomScope::class)
class VoiceRecorderImpl @Inject constructor( class VoiceRecorderImpl @Inject constructor(
private val dispatchers: CoroutineDispatchers, private val dispatchers: CoroutineDispatchers,
private val timeSource: TimeSource,
private val audioReaderFactory: AudioReader.Factory, private val audioReaderFactory: AudioReader.Factory,
private val encoder: Encoder, private val encoder: Encoder,
private val fileManager: VoiceFileManager, private val fileManager: VoiceFileManager,
@ -74,16 +78,27 @@ class VoiceRecorderImpl @Inject constructor(
val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it } val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it }
recordingJob = voiceCoroutineScope.launch { recordingJob = voiceCoroutineScope.launch {
val startedAt = timeSource.markNow()
audioRecorder.record { audio -> audioRecorder.record { audio ->
yield()
val elapsedTime = startedAt.elapsedNow()
if (elapsedTime >= 30.minutes) {
Timber.w("Voice message time limit reached")
stopRecord(false)
return@record
}
when (audio) { when (audio) {
is Audio.Data -> { is Audio.Data -> {
val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer) val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer)
_state.emit(VoiceRecorderState.Recording(audioLevel)) _state.emit(VoiceRecorderState.Recording(elapsedTime, audioLevel))
encoder.encode(audio.buffer, audio.readSize) encoder.encode(audio.buffer, audio.readSize)
} }
is Audio.Error -> { is Audio.Error -> {
Timber.e("Voice message error: code=${audio.audioRecordErrorCode}") Timber.e("Voice message error: code=${audio.audioRecordErrorCode}")
_state.emit(VoiceRecorderState.Recording(0.0)) _state.emit(VoiceRecorderState.Recording(elapsedTime, 0.0))
} }
} }
} }

View file

@ -37,9 +37,13 @@ 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.minutes
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource
class VoiceRecorderImplTest { class VoiceRecorderImplTest {
private val fakeFileSystem = FakeFileSystem() private val fakeFileSystem = FakeFileSystem()
private val timeSource = TestTimeSource()
@Test @Test
fun `it emits the initial state`() = runTest { fun `it emits the initial state`() = runTest {
@ -56,9 +60,27 @@ class VoiceRecorderImplTest {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle) assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord() voiceRecorder.startRecord()
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.0)) assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.seconds, 1.0))
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.0)) timeSource += 1.seconds
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.0)) assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.seconds,0.0))
timeSource += 1.seconds
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(2.seconds, 1.0))
}
}
@Test
fun `when elapsed time reaches 30 minutes, it stops recording`() = runTest {
val voiceRecorder = createVoiceRecorder()
voiceRecorder.state.test {
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
voiceRecorder.startRecord()
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.minutes, 1.0))
timeSource += 29.minutes
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(29.minutes, 0.0))
timeSource += 1.minutes
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Finished(File(FILE_PATH), "audio/ogg"))
} }
} }
@ -94,6 +116,7 @@ class VoiceRecorderImplTest {
val fileConfig = VoiceRecorderModule.provideVoiceFileConfig() val fileConfig = VoiceRecorderModule.provideVoiceFileConfig()
return VoiceRecorderImpl( return VoiceRecorderImpl(
dispatchers = testCoroutineDispatchers(), dispatchers = testCoroutineDispatchers(),
timeSource = timeSource,
audioReaderFactory = FakeAudioRecorderFactory( audioReaderFactory = FakeAudioRecorderFactory(
audio = AUDIO, audio = AUDIO,
), ),

View file

@ -35,6 +35,7 @@ class FakeAudioReader(
while (audios.hasNext()) { while (audios.hasNext()) {
if (!isRecording) break if (!isRecording) break
onAudio(audios.next()) onAudio(audios.next())
yield()
} }
while (isActive) { while (isActive) {
// do not return from the coroutine until it is cancelled // do not return from the coroutine until it is cancelled

View file

@ -21,8 +21,13 @@ import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import java.io.File import java.io.File
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TestTimeSource
class FakeVoiceRecorder( class FakeVoiceRecorder(
private val timeSource: TestTimeSource = TestTimeSource(),
private val recordingDuration: Duration = 0.seconds,
private val levels: List<Double> = listOf(0.1, 0.2) private val levels: List<Double> = listOf(0.1, 0.2)
) : VoiceRecorder { ) : VoiceRecorder {
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle) private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
@ -33,6 +38,7 @@ class FakeVoiceRecorder(
private var securityException: SecurityException? = null private var securityException: SecurityException? = null
override suspend fun startRecord() { override suspend fun startRecord() {
val startedAt = timeSource.markNow()
securityException?.let { throw it } securityException?.let { throw it }
if (curRecording != null) { if (curRecording != null) {
@ -40,8 +46,9 @@ class FakeVoiceRecorder(
} }
curRecording = File("file.ogg") curRecording = File("file.ogg")
timeSource += recordingDuration
levels.forEach { levels.forEach {
_state.emit(VoiceRecorderState.Recording(it)) _state.emit(VoiceRecorderState.Recording(startedAt.elapsedNow(), it))
} }
} }

View file

@ -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.services.toolbox.impl.systemclock
import com.squareup.anvil.annotations.ContributesTo
import dagger.Module
import dagger.Provides
import io.element.android.libraries.di.AppScope
import kotlin.time.TimeSource
@Module
@ContributesTo(AppScope::class)
object TimeModule {
@Provides
fun timeSource(): TimeSource {
return TimeSource.Monotonic
}
}

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:34c05059f7c4f997f3c4af339f548773aafbbcb688d563d559c14c75fba1c70d oid sha256:3c25252b8d43f4ffb58673f375709b4811901a78676580a7dd0288b8624615d7
size 9039 size 7787

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:dd5d861bd3630a0341e6f8ef9bf16eb4135ed770bf549d4713cedb476c974cb2 oid sha256:55fa9c5633d3776a3401db72e2e53d9eacedd745e0db7f259f6ada5d7a8d584a
size 8634 size 7473

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2a06a32b3b576c01074eb14f236f8043d209600fb098fdd3933b01f99055c24d oid sha256:208ad62e23efd6f07e2cce08c1d1511af4c4fc2ae6bc3299134fed9efb1c55d3
size 8069 size 7238

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:cd347e9e28f7518bd4e4169426d9172ef7a107895d9fb93744daf47bf1ae0e50 oid sha256:8cf7662c33de6ad1b58785674cbfce1d6323fe84e35c8398b563ea65e5ff9fa7
size 7681 size 6919

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c1bba507fdd9fc8526408d176f0519f42179a3618ff8e8a41f25b36a13a1a00f oid sha256:2c2eadc9585070e9a07dda78145c337a003166d4dc990e794d71ece0f8cab4f3
size 18323 size 17134

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c268e0a5bbdabb2355cb44820f666d2c4f298e5eeda7720328ad08ac9ac7cdc5 oid sha256:8443556fa3ad8ca7c928689b3fdd2db43cec62ceb8db340353c90cbd7ea76355
size 17357 size 16228