Record and send voice messages (#1596)
--------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
da345f9afa
commit
5c582bba1b
68 changed files with 2274 additions and 82 deletions
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* 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.voicerecorder.impl
|
||||
|
||||
import android.Manifest
|
||||
import androidx.annotation.RequiresPermission
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.coroutine.childScope
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
|
||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.Audio
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.Encoder
|
||||
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
|
||||
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@SingleIn(RoomScope::class)
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class VoiceRecorderImpl @Inject constructor(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val audioReaderFactory: AudioReader.Factory,
|
||||
private val encoder: Encoder,
|
||||
private val fileManager: VoiceFileManager,
|
||||
private val config: AudioConfig,
|
||||
private val fileConfig: VoiceFileConfig,
|
||||
private val audioLevelCalculator: AudioLevelCalculator,
|
||||
appCoroutineScope: CoroutineScope,
|
||||
) : VoiceRecorder {
|
||||
private val voiceCoroutineScope by lazy {
|
||||
appCoroutineScope.childScope(dispatchers.io, "VoiceRecorder-${UUID.randomUUID()}")
|
||||
}
|
||||
|
||||
private var outputFile: File? = null
|
||||
private var audioReader: AudioReader? = null
|
||||
private var recordingJob: Job? = null
|
||||
|
||||
private val _state = MutableStateFlow<VoiceRecorderState>(VoiceRecorderState.Idle)
|
||||
override val state: StateFlow<VoiceRecorderState> = _state
|
||||
|
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||
override suspend fun startRecord() {
|
||||
Timber.i("Voice recorder started recording")
|
||||
outputFile = fileManager.createFile()
|
||||
.also(encoder::init)
|
||||
|
||||
val audioRecorder = audioReaderFactory.create(config, dispatchers).also { audioReader = it }
|
||||
|
||||
recordingJob = voiceCoroutineScope.launch {
|
||||
audioRecorder.record { audio ->
|
||||
when (audio) {
|
||||
is Audio.Data -> {
|
||||
val audioLevel = audioLevelCalculator.calculateAudioLevel(audio.buffer)
|
||||
_state.emit(VoiceRecorderState.Recording(audioLevel))
|
||||
encoder.encode(audio.buffer, audio.readSize)
|
||||
}
|
||||
is Audio.Error -> {
|
||||
Timber.e("Voice message error: code=${audio.audioRecordErrorCode}")
|
||||
_state.emit(VoiceRecorderState.Recording(0.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the current recording.
|
||||
*
|
||||
* Call [deleteRecording] to delete any recorded audio.
|
||||
*/
|
||||
override suspend fun stopRecord(
|
||||
cancelled: Boolean
|
||||
) {
|
||||
recordingJob?.cancel()?.also {
|
||||
Timber.i("Voice recorder stopped recording")
|
||||
}
|
||||
recordingJob = null
|
||||
|
||||
audioReader?.stop()
|
||||
audioReader = null
|
||||
encoder.release()
|
||||
|
||||
if (cancelled) {
|
||||
deleteRecording()
|
||||
}
|
||||
|
||||
_state.emit(
|
||||
when (val file = outputFile) {
|
||||
null -> VoiceRecorderState.Idle
|
||||
else -> VoiceRecorderState.Finished(file, fileConfig.mimeType)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the current recording and delete the output file.
|
||||
*/
|
||||
override suspend fun deleteRecording() {
|
||||
outputFile?.let(fileManager::deleteFile)?.also {
|
||||
Timber.i("Voice recorder deleted recording")
|
||||
}
|
||||
outputFile = null
|
||||
_state.emit(VoiceRecorderState.Idle)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
/*
|
||||
* 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.voicerecorder.impl.audio
|
||||
|
||||
import android.Manifest
|
||||
import android.media.AudioRecord
|
||||
import android.media.audiofx.AutomaticGainControl
|
||||
import android.media.audiofx.NoiseSuppressor
|
||||
import androidx.annotation.RequiresPermission
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.data.tryOrNull
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class AndroidAudioReader
|
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO) private constructor(
|
||||
private val config: AudioConfig,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : AudioReader {
|
||||
private val audioRecord: AudioRecord
|
||||
private var noiseSuppressor: NoiseSuppressor? = null
|
||||
private var automaticGainControl: AutomaticGainControl? = null
|
||||
private val outputBuffer: ShortArray
|
||||
|
||||
init {
|
||||
outputBuffer = createOutputBuffer(config.sampleRate)
|
||||
audioRecord = AudioRecord.Builder().setAudioSource(config.source).setAudioFormat(config.format).setBufferSizeInBytes(outputBuffer.sizeInBytes()).build()
|
||||
noiseSuppressor = requestNoiseSuppressor(audioRecord)
|
||||
automaticGainControl = requestAutomaticGainControl(audioRecord)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record audio data continuously.
|
||||
*
|
||||
* @param onAudio callback when audio is read.
|
||||
*/
|
||||
override suspend fun record(
|
||||
onAudio: suspend (Audio) -> Unit,
|
||||
) {
|
||||
audioRecord.startRecording()
|
||||
withContext(dispatchers.io) {
|
||||
while (isActive) {
|
||||
if (audioRecord.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
|
||||
break
|
||||
}
|
||||
onAudio(read())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun read(): Audio {
|
||||
val result = audioRecord.read(outputBuffer, 0, outputBuffer.size)
|
||||
|
||||
if (isAudioRecordErrorResult(result)) {
|
||||
return Audio.Error(result)
|
||||
}
|
||||
|
||||
return Audio.Data(
|
||||
result,
|
||||
outputBuffer,
|
||||
)
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
if (audioRecord.state == AudioRecord.STATE_INITIALIZED) {
|
||||
audioRecord.stop()
|
||||
}
|
||||
audioRecord.release()
|
||||
|
||||
noiseSuppressor?.release()
|
||||
noiseSuppressor = null
|
||||
|
||||
automaticGainControl?.release()
|
||||
automaticGainControl = null
|
||||
}
|
||||
|
||||
private fun createOutputBuffer(sampleRate: SampleRate): ShortArray {
|
||||
val bufferSizeInShorts = AudioRecord.getMinBufferSize(
|
||||
sampleRate.hz,
|
||||
config.format.channelMask,
|
||||
config.format.encoding
|
||||
)
|
||||
return ShortArray(bufferSizeInShorts)
|
||||
}
|
||||
|
||||
private fun requestNoiseSuppressor(audioRecord: AudioRecord): NoiseSuppressor? {
|
||||
if (!NoiseSuppressor.isAvailable()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return tryOrNull {
|
||||
NoiseSuppressor.create(audioRecord.audioSessionId).apply {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestAutomaticGainControl(audioRecord: AudioRecord): AutomaticGainControl? {
|
||||
if (!AutomaticGainControl.isAvailable()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return tryOrNull {
|
||||
AutomaticGainControl.create(audioRecord.audioSessionId).apply {
|
||||
enabled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
companion object Factory : AudioReader.Factory {
|
||||
@RequiresPermission(Manifest.permission.RECORD_AUDIO)
|
||||
override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AndroidAudioReader {
|
||||
return AndroidAudioReader(config, dispatchers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAudioRecordErrorResult(result: Int): Boolean {
|
||||
return result < 0
|
||||
}
|
||||
|
||||
private fun ShortArray.sizeInBytes(): Int = size * Short.SIZE_BYTES
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
sealed class Audio {
|
||||
class Data(
|
||||
val readSize: Int,
|
||||
val buffer: ShortArray,
|
||||
) : Audio()
|
||||
|
||||
data class Error(
|
||||
val audioRecordErrorCode: Int
|
||||
) : Audio()
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* 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.voicerecorder.impl.audio
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaRecorder.AudioSource
|
||||
|
||||
/**
|
||||
* Audio configuration for voice recording.
|
||||
*
|
||||
* @property source the audio source to use, see constants in [AudioSource]
|
||||
* @property format the audio format to use, see [AudioFormat]
|
||||
* @property sampleRate the sample rate to use. Ensure this matches the value set in [format].
|
||||
* @property bitRate the bitrate in bps
|
||||
*/
|
||||
data class AudioConfig(
|
||||
val source: Int,
|
||||
val format: AudioFormat,
|
||||
val sampleRate: SampleRate,
|
||||
val bitRate: Int,
|
||||
)
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
interface AudioLevelCalculator {
|
||||
/**
|
||||
* Calculate the audio level of the audio buffer.
|
||||
*
|
||||
* @param buffer The audio buffer containing raw audio data.
|
||||
*
|
||||
* @return A value between 0 and 1.
|
||||
*/
|
||||
fun calculateAudioLevel(buffer: ShortArray): Double
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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.voicerecorder.impl.audio
|
||||
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
|
||||
interface AudioReader {
|
||||
/**
|
||||
* Record audio data continuously.
|
||||
*
|
||||
* @param onAudio callback when audio is read.
|
||||
*/
|
||||
suspend fun record(
|
||||
onAudio: suspend (Audio) -> Unit,
|
||||
)
|
||||
|
||||
fun stop()
|
||||
|
||||
interface Factory {
|
||||
fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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.libraries.voicerecorder.impl.audio
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import javax.inject.Inject
|
||||
import kotlin.math.log10
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DecibelAudioLevelCalculator @Inject constructor() : AudioLevelCalculator {
|
||||
companion object {
|
||||
private const val REFERENCE_DB = 50.0 // Reference dB for normal conversation
|
||||
}
|
||||
|
||||
override fun calculateAudioLevel(buffer: ShortArray): Double {
|
||||
val rms = buffer.rootMeanSquare()
|
||||
|
||||
// Convert to decibels and clip
|
||||
val db = 20 * log10(rms / REFERENCE_DB)
|
||||
val clipped = min(db, REFERENCE_DB)
|
||||
|
||||
// Scale to the range [0.0, 1.0]
|
||||
return clipped / REFERENCE_DB
|
||||
}
|
||||
|
||||
private fun ShortArray.rootMeanSquare(): Double {
|
||||
// Use Double to avoid overflow
|
||||
val sumOfSquares: Double = sumOf { it.toDouble() * it.toDouble() }
|
||||
val avgSquare = sumOfSquares / size.toDouble()
|
||||
return sqrt(avgSquare)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* 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.voicerecorder.impl.audio
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.opusencoder.OggOpusEncoder
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Provider
|
||||
|
||||
/**
|
||||
* Safe wrapper for OggOpusEncoder.
|
||||
*/
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DefaultEncoder @Inject constructor(
|
||||
private val encoderProvider: Provider<OggOpusEncoder>,
|
||||
config: AudioConfig,
|
||||
) : Encoder {
|
||||
private val bitRate = config.bitRate
|
||||
private val sampleRate = config.sampleRate.asEncoderModel()
|
||||
|
||||
private var encoder: OggOpusEncoder? = null
|
||||
override fun init(
|
||||
file: File,
|
||||
) {
|
||||
encoder?.release()
|
||||
encoder = encoderProvider.get().apply {
|
||||
init(file.absolutePath, sampleRate)
|
||||
setBitrate(bitRate)
|
||||
// TODO check encoder application: 2048 (voice, default is typically 2049 as audio)
|
||||
}
|
||||
}
|
||||
|
||||
override fun encode(
|
||||
buffer: ShortArray,
|
||||
readSize: Int,
|
||||
) {
|
||||
encoder?.encode(buffer, readSize)
|
||||
?: Timber.w("Can't encode when encoder not initialized")
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
encoder?.release()
|
||||
?: Timber.w("Can't release encoder that is not initialized")
|
||||
encoder = null
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.voicerecorder.impl.audio
|
||||
|
||||
import java.io.File
|
||||
|
||||
interface Encoder {
|
||||
|
||||
fun init(file: File)
|
||||
|
||||
fun encode(buffer: ShortArray, readSize: Int)
|
||||
|
||||
fun release()
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* 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.voicerecorder.impl.audio
|
||||
|
||||
import io.element.android.opusencoder.configuration.SampleRate as LibOpusOggSampleRate
|
||||
|
||||
data object SampleRate {
|
||||
const val hz = 48_000
|
||||
fun asEncoderModel() = LibOpusOggSampleRate.Rate48kHz
|
||||
}
|
||||
|
|
@ -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.libraries.voicerecorder.impl.di
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaRecorder
|
||||
import com.squareup.anvil.annotations.ContributesTo
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.SampleRate
|
||||
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
|
||||
import io.element.android.opusencoder.OggOpusEncoder
|
||||
|
||||
@Module
|
||||
@ContributesTo(RoomScope::class)
|
||||
object VoiceRecorderModule {
|
||||
@Provides
|
||||
fun provideAudioConfig(): AudioConfig {
|
||||
val sampleRate = SampleRate
|
||||
return AudioConfig(
|
||||
format = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(sampleRate.hz)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build(),
|
||||
bitRate = 24_000, // 24 kbps
|
||||
sampleRate = sampleRate,
|
||||
source = MediaRecorder.AudioSource.MIC,
|
||||
)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideVoiceFileConfig(): VoiceFileConfig =
|
||||
VoiceFileConfig(
|
||||
cacheSubdir = "voice_recordings",
|
||||
fileExt = "ogg",
|
||||
mimeType = "audio/ogg",
|
||||
)
|
||||
|
||||
@Provides
|
||||
fun provideOggOpusEncoder(): OggOpusEncoder = OggOpusEncoder.create()
|
||||
}
|
||||
|
|
@ -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.libraries.voicerecorder.impl.file
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.core.hash.md5
|
||||
import io.element.android.libraries.di.CacheDirectory
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(RoomScope::class)
|
||||
class DefaultVoiceFileManager @Inject constructor(
|
||||
@CacheDirectory private val cacheDir: File,
|
||||
private val config: VoiceFileConfig,
|
||||
room: MatrixRoom,
|
||||
) : VoiceFileManager {
|
||||
|
||||
private val roomId: RoomId = room.roomId
|
||||
|
||||
override fun createFile(): File {
|
||||
val fileName = "${UUID.randomUUID()}.${config.fileExt}"
|
||||
val outputDirectory = File(cacheDir, config.cacheSubdir)
|
||||
val roomDir = File(outputDirectory, roomId.value.md5())
|
||||
.apply(File::mkdirs)
|
||||
return File(roomDir, fileName)
|
||||
}
|
||||
|
||||
override fun deleteFile(file: File) {
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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.voicerecorder.impl.file
|
||||
|
||||
/**
|
||||
* File configuration for voice recording.
|
||||
*
|
||||
* @property cacheSubdir the subdirectory in the cache dir to use.
|
||||
* @property fileExt the file extension for audio files.
|
||||
* @property mimeType the mime type of audio files.
|
||||
*/
|
||||
data class VoiceFileConfig(
|
||||
val cacheSubdir: String,
|
||||
val fileExt: String,
|
||||
val mimeType: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* 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.voicerecorder.impl.file
|
||||
|
||||
import java.io.File
|
||||
|
||||
interface VoiceFileManager {
|
||||
fun createFile(): File
|
||||
|
||||
fun deleteFile(file: File)
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* 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.voicerecorder.impl
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaRecorder
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.Audio
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.SampleRate
|
||||
import io.element.android.libraries.voicerecorder.impl.di.VoiceRecorderModule
|
||||
import io.element.android.libraries.voicerecorder.test.FakeAudioLevelCalculator
|
||||
import io.element.android.libraries.voicerecorder.test.FakeAudioRecorderFactory
|
||||
import io.element.android.libraries.voicerecorder.test.FakeEncoder
|
||||
import io.element.android.libraries.voicerecorder.test.FakeFileSystem
|
||||
import io.element.android.libraries.voicerecorder.test.FakeVoiceFileManager
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.BeforeClass
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
class VoiceRecorderImplTest {
|
||||
private val fakeFileSystem = FakeFileSystem()
|
||||
|
||||
@Test
|
||||
fun `it emits the initial state`() = runTest {
|
||||
val voiceRecorder = createVoiceRecorder()
|
||||
voiceRecorder.state.test {
|
||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when recording, it emits the recording state`() = runTest {
|
||||
val voiceRecorder = createVoiceRecorder()
|
||||
voiceRecorder.state.test {
|
||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
||||
|
||||
voiceRecorder.startRecord()
|
||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.0))
|
||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(0.0))
|
||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Recording(1.0))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when stopped, it provides a file`() = runTest {
|
||||
val voiceRecorder = createVoiceRecorder()
|
||||
voiceRecorder.state.test {
|
||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
||||
|
||||
voiceRecorder.startRecord()
|
||||
skipItems(3)
|
||||
voiceRecorder.stopRecord()
|
||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Finished(File(FILE_PATH), "audio/ogg"))
|
||||
assertThat(fakeFileSystem.files[File(FILE_PATH)]).isEqualTo(ENCODED_DATA)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `when cancelled, it deletes the file`() = runTest {
|
||||
val voiceRecorder = createVoiceRecorder()
|
||||
voiceRecorder.state.test {
|
||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
||||
|
||||
voiceRecorder.startRecord()
|
||||
skipItems(3)
|
||||
voiceRecorder.stopRecord(cancelled = true)
|
||||
assertThat(awaitItem()).isEqualTo(VoiceRecorderState.Idle)
|
||||
assertThat(fakeFileSystem.files[File(FILE_PATH)]).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createVoiceRecorder(): VoiceRecorderImpl {
|
||||
val fileConfig = VoiceRecorderModule.provideVoiceFileConfig()
|
||||
return VoiceRecorderImpl(
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
audioReaderFactory = FakeAudioRecorderFactory(
|
||||
audio = AUDIO,
|
||||
),
|
||||
encoder = FakeEncoder(fakeFileSystem),
|
||||
config = AudioConfig(
|
||||
format = AUDIO_FORMAT,
|
||||
bitRate = 24_000, // 24 kbps
|
||||
sampleRate = SampleRate,
|
||||
source = MediaRecorder.AudioSource.MIC,
|
||||
),
|
||||
fileConfig = fileConfig,
|
||||
fileManager = FakeVoiceFileManager(fakeFileSystem, fileConfig, FILE_ID),
|
||||
audioLevelCalculator = FakeAudioLevelCalculator(),
|
||||
appCoroutineScope = backgroundScope,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FILE_ID: String = "recording"
|
||||
const val FILE_PATH = "voice_recordings/${FILE_ID}.ogg"
|
||||
private lateinit var AUDIO_FORMAT: AudioFormat
|
||||
|
||||
// FakeEncoder doesn't actually encode, it just writes the data to the file
|
||||
private const val ENCODED_DATA = "[32767, 32767, 32767][32767, 32767, 32767]"
|
||||
private const val MAX_AMP = Short.MAX_VALUE
|
||||
private val AUDIO = listOf(
|
||||
Audio.Data(3, shortArrayOf(MAX_AMP, MAX_AMP, MAX_AMP)),
|
||||
Audio.Error(-1),
|
||||
Audio.Data(3, shortArrayOf(MAX_AMP, MAX_AMP, MAX_AMP)),
|
||||
)
|
||||
|
||||
@BeforeClass
|
||||
@JvmStatic
|
||||
fun initAudioFormat() {
|
||||
AUDIO_FORMAT = mockk()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
* 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.voicerecorder.impl.audio
|
||||
|
||||
import org.junit.Test
|
||||
|
||||
class DecibelAudioLevelCalculatorTest {
|
||||
|
||||
@Test
|
||||
fun `given max values, it returns values within range`() {
|
||||
val calculator = DecibelAudioLevelCalculator()
|
||||
val buffer = ShortArray(100) { Short.MAX_VALUE }
|
||||
val level = calculator.calculateAudioLevel(buffer)
|
||||
assert(level in 0.0..1.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given mixed values, it returns values within range`() {
|
||||
val calculator = DecibelAudioLevelCalculator()
|
||||
val buffer = shortArrayOf(Short.MAX_VALUE, Short.MIN_VALUE, -1, 1)
|
||||
val level = calculator.calculateAudioLevel(buffer)
|
||||
assert(level in 0.0..1.0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `given min values, it returns values within range`() {
|
||||
val calculator = DecibelAudioLevelCalculator()
|
||||
val buffer = ShortArray(100) { Short.MIN_VALUE }
|
||||
val level = calculator.calculateAudioLevel(buffer)
|
||||
assert(level in 0.0..1.0)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* 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.voicerecorder.test
|
||||
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioLevelCalculator
|
||||
import kotlin.math.abs
|
||||
|
||||
class FakeAudioLevelCalculator: AudioLevelCalculator {
|
||||
override fun calculateAudioLevel(buffer: ShortArray): Double {
|
||||
return buffer.map { abs(it.toDouble()) }.average() / Short.MAX_VALUE
|
||||
}
|
||||
}
|
||||
|
|
@ -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.libraries.voicerecorder.test
|
||||
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.Audio
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.yield
|
||||
|
||||
class FakeAudioReader(
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
private val audio: List<Audio>,
|
||||
) : AudioReader {
|
||||
private var isRecording = false
|
||||
override suspend fun record(onAudio: suspend (Audio) -> Unit) {
|
||||
isRecording = true
|
||||
withContext(dispatchers.io) {
|
||||
val audios = audio.iterator()
|
||||
while (audios.hasNext()) {
|
||||
if (!isRecording) break
|
||||
onAudio(audios.next())
|
||||
}
|
||||
while (isActive) {
|
||||
// do not return from the coroutine until it is cancelled
|
||||
yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
isRecording = false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* 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.voicerecorder.test
|
||||
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.Audio
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.AudioReader
|
||||
|
||||
class FakeAudioRecorderFactory(
|
||||
private val audio: List<Audio>
|
||||
): AudioReader.Factory {
|
||||
override fun create(config: AudioConfig, dispatchers: CoroutineDispatchers): AudioReader {
|
||||
return FakeAudioReader(dispatchers, audio)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
/*
|
||||
* 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.voicerecorder.test
|
||||
|
||||
import io.element.android.libraries.voicerecorder.impl.audio.Encoder
|
||||
import java.io.File
|
||||
|
||||
class FakeEncoder(
|
||||
private val fakeFileSystem: FakeFileSystem
|
||||
) : Encoder {
|
||||
private var curFile: File? = null
|
||||
override fun init(file: File) {
|
||||
curFile = file
|
||||
}
|
||||
|
||||
override fun encode(buffer: ShortArray, readSize: Int) {
|
||||
val file = curFile
|
||||
?: error("Encoder not initialized")
|
||||
|
||||
fakeFileSystem.appendToFile(file, buffer, readSize)
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
curFile = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* 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.voicerecorder.test
|
||||
|
||||
import java.io.File
|
||||
|
||||
class FakeFileSystem {
|
||||
// Map of file to file content
|
||||
val files = mutableMapOf<File, String>()
|
||||
|
||||
fun createFile(file: File) {
|
||||
if(files.containsKey(file)) {
|
||||
return
|
||||
}
|
||||
|
||||
files[file] = ""
|
||||
}
|
||||
|
||||
fun appendToFile(file: File, buffer: ShortArray, readSize: Int) {
|
||||
val content = files[file]
|
||||
?: error("File ${file.path} does not exist")
|
||||
|
||||
files[file] = content + buffer.sliceArray(0 until readSize).contentToString()
|
||||
}
|
||||
|
||||
fun deleteFile(file: File) {
|
||||
files.remove(file)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* 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.voicerecorder.test
|
||||
|
||||
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileConfig
|
||||
import io.element.android.libraries.voicerecorder.impl.file.VoiceFileManager
|
||||
import java.io.File
|
||||
|
||||
class FakeVoiceFileManager(
|
||||
private val fakeFileSystem: FakeFileSystem,
|
||||
private val config: VoiceFileConfig,
|
||||
private val fileId: String,
|
||||
) : VoiceFileManager {
|
||||
override fun createFile(): File {
|
||||
val file = File("${config.cacheSubdir}/$fileId.${config.fileExt}")
|
||||
fakeFileSystem.createFile(file)
|
||||
return file
|
||||
}
|
||||
|
||||
override fun deleteFile(file: File) {
|
||||
fakeFileSystem.deleteFile(file)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue