android, desktop: sound prompts and vibration (#3997)

* android, desktop: sound prompts and vibration

* moved audio playing to js

* Revert "moved audio playing to js"

This reverts commit a9355c2d88.

* sound from speaker/earpiece

* better

* changes

* license of audio

* reorder lines

* loop fix

* awaiting call receipt

* desktop

* new line
This commit is contained in:
Stanislav Dmitrenko
2024-04-11 13:19:00 +01:00
committed by GitHub
parent 94851f177b
commit 34dee32def
12 changed files with 175 additions and 24 deletions
@@ -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)
}
}
@@ -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 {
@@ -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)
@@ -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<APIResponse> = 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)
@@ -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
@@ -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
}
}
}
@@ -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()
}
}
@@ -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()
}