diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/SoundPlayer.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/SoundPlayer.kt index ff83c10df3..d567b3f7f9 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/SoundPlayer.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/helpers/SoundPlayer.kt @@ -2,17 +2,16 @@ package chat.simplex.common.helpers import android.media.* import android.net.Uri -import android.os.VibrationEffect -import android.os.Vibrator +import android.os.* import androidx.core.content.ContextCompat import chat.simplex.common.R -import chat.simplex.common.platform.SoundPlayerInterface -import chat.simplex.common.platform.androidAppContext +import chat.simplex.common.platform.* +import chat.simplex.common.views.helpers.withApi import kotlinx.coroutines.* object SoundPlayer: SoundPlayerInterface { private var player: MediaPlayer? = null - var playing = false + private var playing = false override fun start(scope: CoroutineScope, sound: Boolean) { player?.reset() @@ -32,7 +31,7 @@ object SoundPlayer: SoundPlayerInterface { scope.launch { while (playing) { if (sound) player?.start() - vibrator?.vibrate(effect) + vibrator?.vibrateApiVersionAware(effect) delay(3500) } } @@ -43,3 +42,82 @@ object SoundPlayer: SoundPlayerInterface { player?.stop() } } + +object CallSoundsPlayer: CallSoundsPlayerInterface { + private var player: MediaPlayer? = null + private var playingJob: Job? = null + + private fun start(soundPath: String, delay: Long, scope: CoroutineScope) { + playingJob?.cancel() + player?.reset() + player = MediaPlayer().apply { + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING) + .build() + ) + setDataSource(androidAppContext, Uri.parse(soundPath)) + prepare() + } + if (delay < 1000) { + player?.isLooping = true + player?.start() + return + } + playingJob = scope.launch { + while (isActive) { + player?.start() + delay(delay) + } + } + } + + override fun startConnectingCallSound(scope: CoroutineScope) { + // Taken from https://github.com/TelegramOrg/Telegram-Android + // https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE + start("android.resource://" + androidAppContext.packageName + "/" + R.raw.connecting_call, 0, scope) + } + + override fun startInCallSound(scope: CoroutineScope) { + start("android.resource://" + androidAppContext.packageName + "/" + R.raw.in_call, 2000, scope) + } + + override fun vibrate(times: Int) { + val vibrator = ContextCompat.getSystemService(androidAppContext, Vibrator::class.java) + val effect = VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE) + vibrator?.vibrateApiVersionAware(effect) + repeat(times - 1) { + withApi { + delay(50) + vibrator?.vibrateApiVersionAware(effect) + } + } + } + + override fun stop() { + playingJob?.cancel() + player?.stop() + } +} + +private fun Vibrator.vibrateApiVersionAware(effect: VibrationEffect) { + if (Build.VERSION.SDK_INT >= 33) { + vibrate( + effect, + VibrationAttributes.Builder() + .setUsage(VibrationAttributes.USAGE_ALARM) + .build() + ) + } else if (Build.VERSION.SDK_INT >= 29) { + vibrate( + effect, + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_ALARM) + .build() + ) + } else { + vibrate(effect) + } +} diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt index 5b0d3c778f..5cb10ff070 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/platform/RecAndPlay.android.kt @@ -296,6 +296,7 @@ actual object AudioPlayer: AudioPlayerInterface { } actual typealias SoundPlayer = chat.simplex.common.helpers.SoundPlayer +actual typealias CallSoundsPlayer = chat.simplex.common.helpers.CallSoundsPlayer class CryptoMediaSource(val data: ByteArray) : MediaDataSource() { override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int { diff --git a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt index b0c4beded0..af261e2a98 100644 --- a/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt +++ b/apps/multiplatform/common/src/androidMain/kotlin/chat/simplex/common/views/call/CallView.android.kt @@ -69,6 +69,8 @@ fun activeCallDestroyWebView() = withApi { @SuppressLint("SourceLockedOrientationActivity") @Composable actual fun ActiveCallView() { + val call = remember { chatModel.activeCall }.value + val scope = rememberCoroutineScope() val audioViaBluetooth = rememberSaveable { mutableStateOf(false) } val proximityLock = remember { val pm = (androidAppContext.getSystemService(Context.POWER_SERVICE) as PowerManager) @@ -78,6 +80,13 @@ actual fun ActiveCallView() { null } } + val wasConnected = rememberSaveable { mutableStateOf(false) } + LaunchedEffect(call) { + if (call?.callState == CallState.Connected && !wasConnected.value) { + CallSoundsPlayer.vibrate(2) + wasConnected.value = true + } + } DisposableEffect(Unit) { val am = androidAppContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager var btDeviceCount = 0 @@ -107,6 +116,10 @@ actual fun ActiveCallView() { } am.registerAudioDeviceCallback(audioCallback, null) onDispose { + CallSoundsPlayer.stop() + if (wasConnected.value) { + CallSoundsPlayer.vibrate() + } dropAudioManagerOverrides() am.unregisterAudioDeviceCallback(audioCallback) if (proximityLock?.isHeld == true) { @@ -122,8 +135,6 @@ actual fun ActiveCallView() { if (proximityLock?.isHeld == false) proximityLock.acquire() } } - val scope = rememberCoroutineScope() - val call = chatModel.activeCall.value Box(Modifier.fillMaxSize()) { WebRTCView(chatModel.callCommand) { apiMsg -> Log.d(TAG, "received from WebRTCView: $apiMsg") @@ -136,6 +147,9 @@ actual fun ActiveCallView() { val callType = CallType(call.localMedia, r.capabilities) chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType) updateActiveCall(call) { it.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) } + setCallSound(call.soundSpeaker, audioViaBluetooth) + CallSoundsPlayer.startConnectingCallSound(scope) + activeCallWaitDeliveryReceipt(scope) } is WCallResponse.Offer -> withBGApi { chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) @@ -144,6 +158,7 @@ actual fun ActiveCallView() { is WCallResponse.Answer -> withBGApi { chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates) updateActiveCall(call) { it.copy(callState = CallState.Negotiated) } + CallSoundsPlayer.stop() } is WCallResponse.Ice -> withBGApi { chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) diff --git a/apps/multiplatform/common/src/androidMain/res/raw/connecting_call.mp3 b/apps/multiplatform/common/src/androidMain/res/raw/connecting_call.mp3 new file mode 100644 index 0000000000..fc425bab97 Binary files /dev/null and b/apps/multiplatform/common/src/androidMain/res/raw/connecting_call.mp3 differ diff --git a/apps/multiplatform/common/src/androidMain/res/raw/in_call.mp3 b/apps/multiplatform/common/src/androidMain/res/raw/in_call.mp3 new file mode 100644 index 0000000000..1049be4462 Binary files /dev/null and b/apps/multiplatform/common/src/androidMain/res/raw/in_call.mp3 differ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 3ce9b68b5e..7848f9942e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -20,6 +20,7 @@ import com.charleskorn.kaml.YamlConfiguration import chat.simplex.res.MR import com.russhwolf.settings.Settings import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.Instant @@ -350,6 +351,8 @@ object ChatController { var ctrl: ChatCtrl? = -1 val appPrefs: AppPreferences by lazy { AppPreferences() } + val messagesChannel: Channel = Channel() + val chatModel = ChatModel private var receiverStarted = false var lastMsgReceivedTimestamp: Long = System.currentTimeMillis() @@ -481,6 +484,7 @@ object ChatController { if (msg != null) { val finishedWithoutTimeout = withTimeoutOrNull(60_000L) { processReceivedMsg(msg) + messagesChannel.trySend(msg) } if (finishedWithoutTimeout == null) { Log.e(TAG, "Timeout reached while processing received message: " + msg.resp.responseType) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt index 0e0f769487..1e902b5d88 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/platform/RecAndPlay.kt @@ -39,4 +39,13 @@ interface SoundPlayerInterface { fun stop() } +interface CallSoundsPlayerInterface { + fun startConnectingCallSound(scope: CoroutineScope) + fun startInCallSound(scope: CoroutineScope) + fun stop() + fun vibrate(times: Int = 1) +} + expect object SoundPlayer: SoundPlayerInterface + +expect object CallSoundsPlayer: CallSoundsPlayerInterface diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt index 2f4ffbb836..e4a6691d49 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/call/CallView.kt @@ -1,6 +1,26 @@ package chat.simplex.common.views.call import androidx.compose.runtime.Composable +import chat.simplex.common.model.* +import chat.simplex.common.model.ChatModel.controller +import chat.simplex.common.platform.* +import kotlinx.coroutines.* @Composable expect fun ActiveCallView() + +fun activeCallWaitDeliveryReceipt(scope: CoroutineScope) = scope.launch(Dispatchers.Default) { + for (apiResp in controller.messagesChannel) { + val call = chatModel.activeCall.value + if (call == null || call.callState > CallState.InvitationSent) break + val msg = apiResp.resp + if (apiResp.remoteHostId == call.remoteHostId && + msg is CR.ChatItemStatusUpdated && + msg.chatItem.chatInfo.id == call.contact.id && + msg.chatItem.chatItem.content is CIContent.SndCall && + msg.chatItem.chatItem.meta.itemStatus is CIStatus.SndRcvd) { + CallSoundsPlayer.startInCallSound(scope) + break + } + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt index 50d143aaf5..21a2ccd196 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/platform/RecAndPlay.desktop.kt @@ -213,7 +213,7 @@ actual object SoundPlayer: SoundPlayerInterface { override fun start(scope: CoroutineScope, sound: Boolean) { val tmpFile = File(tmpDir, UUID.randomUUID().toString()) tmpFile.deleteOnExit() - SoundPlayer::class.java.getResource("/media/ring_once.mp3").openStream()!!.use { it.copyTo(tmpFile.outputStream()) } + SoundPlayer::class.java.getResource("/media/ring_once.mp3")!!.openStream()!!.use { it.copyTo(tmpFile.outputStream()) } playing = true scope.launch { while (playing && sound) { @@ -228,3 +228,37 @@ actual object SoundPlayer: SoundPlayerInterface { AudioPlayer.stop() } } + +actual object CallSoundsPlayer: CallSoundsPlayerInterface { + private var playingJob: Job? = null + + private fun start(soundPath: String, delay: Long, scope: CoroutineScope) { + playingJob?.cancel() + val tmpFile = File(tmpDir, UUID.randomUUID().toString()) + tmpFile.deleteOnExit() + SoundPlayer::class.java.getResource(soundPath)!!.openStream()!!.use { it.copyTo(tmpFile.outputStream()) } + playingJob = scope.launch { + while (isActive) { + AudioPlayer.play(CryptoFile.plain(tmpFile.absolutePath), mutableStateOf(true), mutableStateOf(0), mutableStateOf(0), true) + delay(delay) + } + } + } + + override fun startConnectingCallSound(scope: CoroutineScope) { + // Taken from https://github.com/TelegramOrg/Telegram-Android + // https://github.com/TelegramOrg/Telegram-Android/blob/master/LICENSE + start("/media/connecting_call.mp3", 3000, scope) + } + + override fun startInCallSound(scope: CoroutineScope) { + start("/media/in_call.mp3", 5000, scope) + } + + override fun vibrate(times: Int) {} + + override fun stop() { + playingJob?.cancel() + AudioPlayer.stop() + } +} diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt index 87c2d3e8f2..d3bf1bf01e 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/call/CallView.desktop.kt @@ -1,30 +1,15 @@ package chat.simplex.common.views.call -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.snapshots.SnapshotStateList -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler -import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.unit.dp import chat.simplex.common.model.* import chat.simplex.common.platform.* -import chat.simplex.common.ui.theme.* -import chat.simplex.common.views.chat.item.ItemAction import chat.simplex.common.views.helpers.* import chat.simplex.res.MR -import dev.icerock.moko.resources.compose.painterResource -import dev.icerock.moko.resources.compose.stringResource import kotlinx.coroutines.delay import kotlinx.coroutines.flow.* import kotlinx.datetime.Clock -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import org.nanohttpd.protocols.http.IHTTPSession import org.nanohttpd.protocols.http.response.Response @@ -45,6 +30,7 @@ actual fun ActiveCallView() { if (call != null) withBGApi { chatModel.callManager.endCall(call) } } BackHandler(onBack = endCall) + val scope = rememberCoroutineScope() WebRTCController(chatModel.callCommand) { apiMsg -> Log.d(TAG, "received from WebRTCController: $apiMsg") val call = chatModel.activeCall.value @@ -56,6 +42,8 @@ actual fun ActiveCallView() { val callType = CallType(call.localMedia, r.capabilities) chatModel.controller.apiSendCallInvitation(callRh, call.contact, callType) chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities) + CallSoundsPlayer.startConnectingCallSound(scope) + activeCallWaitDeliveryReceipt(scope) } is WCallResponse.Offer -> withBGApi { chatModel.controller.apiSendCallOffer(callRh, call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities) @@ -64,6 +52,7 @@ actual fun ActiveCallView() { is WCallResponse.Answer -> withBGApi { chatModel.controller.apiSendCallAnswer(callRh, call.contact, r.answer, r.iceCandidates) chatModel.activeCall.value = call.copy(callState = CallState.Negotiated) + CallSoundsPlayer.stop() } is WCallResponse.Ice -> withBGApi { chatModel.controller.apiSendCallExtraInfo(callRh, call.contact, r.iceCandidates) @@ -121,6 +110,7 @@ actual fun ActiveCallView() { // After the first call, End command gets added to the list which prevents making another calls chatModel.callCommand.removeAll { it is WCallCommand.End } onDispose { + CallSoundsPlayer.stop() chatModel.activeCallViewIsVisible.value = false chatModel.callCommand.clear() } diff --git a/apps/multiplatform/common/src/desktopMain/resources/media/connecting_call.mp3 b/apps/multiplatform/common/src/desktopMain/resources/media/connecting_call.mp3 new file mode 100644 index 0000000000..fc425bab97 Binary files /dev/null and b/apps/multiplatform/common/src/desktopMain/resources/media/connecting_call.mp3 differ diff --git a/apps/multiplatform/common/src/desktopMain/resources/media/in_call.mp3 b/apps/multiplatform/common/src/desktopMain/resources/media/in_call.mp3 new file mode 100644 index 0000000000..1049be4462 Binary files /dev/null and b/apps/multiplatform/common/src/desktopMain/resources/media/in_call.mp3 differ