diff --git a/composeApp/src/androidMain/kotlin/com/github/familyvault/services/AudioRecorderService.kt b/composeApp/src/androidMain/kotlin/com/github/familyvault/services/AudioRecorderService.kt index 8b25d87f..642b6c9c 100644 --- a/composeApp/src/androidMain/kotlin/com/github/familyvault/services/AudioRecorderService.kt +++ b/composeApp/src/androidMain/kotlin/com/github/familyvault/services/AudioRecorderService.kt @@ -7,6 +7,7 @@ import android.content.pm.PackageManager import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder +import androidx.annotation.RequiresPermission import androidx.core.app.ActivityCompat import com.github.familyvault.AppConfig import kotlinx.coroutines.CoroutineScope @@ -35,6 +36,7 @@ class AudioRecorderService( private val SAMPLE_RATE = AppConfig.AUDIO_SAMPLE_RATE } + @RequiresPermission(Manifest.permission.RECORD_AUDIO) override fun start() { if (isRecording || !haveRecordingPermission()) { diff --git a/composeApp/src/iosMain/kotlin/com/github/familyvault/services/AudioPlayerService.kt b/composeApp/src/iosMain/kotlin/com/github/familyvault/services/AudioPlayerService.kt index fabcad53..a1d13d57 100644 --- a/composeApp/src/iosMain/kotlin/com/github/familyvault/services/AudioPlayerService.kt +++ b/composeApp/src/iosMain/kotlin/com/github/familyvault/services/AudioPlayerService.kt @@ -1,21 +1,63 @@ package com.github.familyvault.services -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.usePinned +import platform.AVFAudio.AVAudioPlayer +import platform.AVFAudio.AVAudioPlayerDelegateProtocol +import platform.AVFAudio.AVAudioSession +import platform.AVFAudio.AVAudioSessionCategoryOptionDefaultToSpeaker +import platform.AVFAudio.AVAudioSessionCategoryPlayAndRecord +import platform.AVFAudio.setActive +import platform.Foundation.NSData +import platform.Foundation.dataWithBytes +import platform.darwin.NSObject class AudioPlayerService : IAudioPlayerService { + private var audioPlayer: AVAudioPlayer? = null + private var listener: AudioPlayerListener? = null - private var playingJob: Job? = null - - private val audioPlayerScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - + @OptIn(ExperimentalForeignApi::class) override fun play(audioData: ByteArray, onCompletion: (() -> Unit)?) { - TODO("Method not yet implemented") + prepareAudioSession() + audioPlayer = AVAudioPlayer(data = audioData.asData(), null) + audioPlayer?.prepareToPlay() + audioPlayer?.play() + listener = AudioPlayerListener { + onCompletion?.invoke() + } + audioPlayer?.delegate = listener } override fun stop() { - TODO("Method not yet implemented") + audioPlayer?.stop() + audioPlayer = null + listener = null } + + @OptIn(ExperimentalForeignApi::class) + private fun prepareAudioSession() { + AVAudioSession.sharedInstance().apply { + setCategory( + AVAudioSessionCategoryPlayAndRecord, + AVAudioSessionCategoryOptionDefaultToSpeaker, + null + ) + setActive(true, null) + } + } + } + +@OptIn(ExperimentalForeignApi::class) +fun ByteArray.asData(): NSData = usePinned { + NSData.dataWithBytes(it.addressOf(0), size.toULong()) +} + +class AudioPlayerListener( + private val didFinish: () -> Unit = {} +) : AVAudioPlayerDelegateProtocol, NSObject() { + override fun audioPlayerDidFinishPlaying(player: AVAudioPlayer, successfully: Boolean) { + didFinish() + } +} \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/github/familyvault/services/AudioRecorderService.kt b/composeApp/src/iosMain/kotlin/com/github/familyvault/services/AudioRecorderService.kt index 5f8b462a..fce3f9bb 100644 --- a/composeApp/src/iosMain/kotlin/com/github/familyvault/services/AudioRecorderService.kt +++ b/composeApp/src/iosMain/kotlin/com/github/familyvault/services/AudioRecorderService.kt @@ -1,37 +1,98 @@ package com.github.familyvault.services import com.github.familyvault.AppConfig -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch - -class AudioRecorderService( -) : IAudioRecorderService { +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.usePinned +import platform.AVFAudio.AVAudioRecorder +import platform.AVFAudio.AVAudioSession +import platform.AVFAudio.AVAudioSessionCategoryPlayAndRecord +import platform.AVFAudio.AVAudioSessionRecordPermissionGranted +import platform.AVFAudio.AVFormatIDKey +import platform.AVFAudio.AVNumberOfChannelsKey +import platform.AVFAudio.AVSampleRateKey +import platform.AVFAudio.setActive +import platform.CoreAudioTypes.kAudioFormatOpus +import platform.Foundation.NSApplicationSupportDirectory +import platform.Foundation.NSData +import platform.Foundation.NSFileManager +import platform.Foundation.NSUserDomainMask +import platform.Foundation.dataWithContentsOfFile +import platform.posix.memcpy +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid +@OptIn(ExperimentalForeignApi::class) +class AudioRecorderService() : IAudioRecorderService{ + private var recorder: AVAudioRecorder? = null private var isRecording = false - private var recordingJob: Job? = null - - private val bufferSize = 0 + //TODO: This should be available from common source companion object { private const val RECORDING_PERMISSION_REQUEST_CODE = 1002 private val SAMPLE_RATE = AppConfig.AUDIO_SAMPLE_RATE + private val AUDIO_SETTINGS = mapOf( + AVFormatIDKey to kAudioFormatOpus, + AVSampleRateKey to SAMPLE_RATE, + AVNumberOfChannelsKey to 1.toUInt(), + ) } + + @OptIn(ExperimentalUuidApi::class) override fun start() { - TODO("Method not yet implemented") + if (isRecording || !haveRecordingPermission()) return + + //TODO: This file name should be correct prepared after implement local cache for audio files + val fileName = Uuid.random() + val file = NSFileManager.defaultManager.URLForDirectory( + NSApplicationSupportDirectory, + NSUserDomainMask, + null, + true, + null + )?.URLByAppendingPathComponent("files/audio/recorded/$fileName.opus") + NSFileManager.defaultManager.createDirectoryAtURL(file!!.URLByDeletingLastPathComponent!!,true,null,null) + + prepareSession() + recorder = AVAudioRecorder(file, AUDIO_SETTINGS, null) + recorder?.let { recorder -> + recorder.prepareToRecord() + recorder.record() + isRecording = true + } } override fun stop() : ByteArray { - TODO("Method not yet implemented") + recorder?.stop() + return NSData.dataWithContentsOfFile(recorder!!.url.path!!)!!.toByteArray() } override fun requestRecordingPermission() { - TODO("Method not yet implemented") + AVAudioSession.sharedInstance().requestRecordPermission {} } override fun haveRecordingPermission(): Boolean { - TODO("Method not yet implemented") + return AVAudioSession.sharedInstance().recordPermission == AVAudioSessionRecordPermissionGranted + } + + private fun prepareSession(){ + AVAudioSession.sharedInstance().apply { + setCategory( + AVAudioSessionCategoryPlayAndRecord, + null + ) + setActive(true,null) + } + } +} + +@OptIn(ExperimentalForeignApi::class) +fun NSData.toByteArray(): ByteArray = memScoped { + ByteArray(length.toInt()).apply { + usePinned { + memcpy(it.addressOf(0), bytes, length) + } } } \ No newline at end of file diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 412e3781..d7eb4a84 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -2,6 +2,8 @@ + NSMicrophoneUsageDescription + This app use microphone for record audio files in chat. CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable