Merge branch 'master' into master-ghc8107
@@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -43,6 +44,9 @@ import chat.simplex.res.MR
|
||||
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||
import dev.icerock.moko.resources.StringResource
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
||||
@@ -52,7 +56,7 @@ actual fun ActiveCallView() {
|
||||
val chatModel = ChatModel
|
||||
BackHandler(onBack = {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
|
||||
})
|
||||
val audioViaBluetooth = rememberSaveable { mutableStateOf(false) }
|
||||
val ntfModeService = remember { chatModel.controller.appPrefs.notificationsMode.get() == NotificationsMode.SERVICE }
|
||||
@@ -112,30 +116,30 @@ actual fun ActiveCallView() {
|
||||
if (call != null) {
|
||||
Log.d(TAG, "has active call $call")
|
||||
when (val r = apiMsg.resp) {
|
||||
is WCallResponse.Capabilities -> withApi {
|
||||
is WCallResponse.Capabilities -> withBGApi {
|
||||
val callType = CallType(call.localMedia, r.capabilities)
|
||||
chatModel.controller.apiSendCallInvitation(call.contact, callType)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
|
||||
}
|
||||
is WCallResponse.Offer -> withApi {
|
||||
is WCallResponse.Offer -> withBGApi {
|
||||
chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
|
||||
}
|
||||
is WCallResponse.Answer -> withApi {
|
||||
is WCallResponse.Answer -> withBGApi {
|
||||
chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
}
|
||||
is WCallResponse.Ice -> withApi {
|
||||
is WCallResponse.Ice -> withBGApi {
|
||||
chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
|
||||
}
|
||||
is WCallResponse.Connection ->
|
||||
try {
|
||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||
if (callStatus == WebRTCCallStatus.Connected) {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now())
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
||||
withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
||||
} catch (e: Error) {
|
||||
Log.d(TAG,"call status ${r.state.connectionState} not used")
|
||||
}
|
||||
@@ -145,9 +149,12 @@ actual fun ActiveCallView() {
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
}
|
||||
is WCallResponse.End -> {
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
is WCallResponse.Ended -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
|
||||
withApi { chatModel.callManager.endCall(call) }
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
chatModel.showCallView.value = false
|
||||
}
|
||||
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
|
||||
@@ -162,7 +169,7 @@ actual fun ActiveCallView() {
|
||||
is WCallCommand.Camera -> {
|
||||
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
|
||||
if (!call.audioEnabled) {
|
||||
chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = false)
|
||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
|
||||
}
|
||||
}
|
||||
is WCallCommand.End ->
|
||||
@@ -187,11 +194,14 @@ actual fun ActiveCallView() {
|
||||
// Lock orientation to portrait in order to have good experience with calls
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
|
||||
chatModel.activeCallViewIsVisible.value = true
|
||||
// After the first call, End command gets added to the list which prevents making another calls
|
||||
chatModel.callCommand.removeAll { it is WCallCommand.End }
|
||||
onDispose {
|
||||
activity.volumeControlStream = prevVolumeControlStream
|
||||
// Unlock orientation
|
||||
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
|
||||
chatModel.activeCallViewIsVisible.value = false
|
||||
chatModel.callCommand.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,9 +211,9 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
|
||||
ActiveCallOverlayLayout(
|
||||
call = call,
|
||||
speakerCanBeEnabled = !audioViaBluetooth.value,
|
||||
dismiss = { withApi { chatModel.callManager.endCall(call) } },
|
||||
toggleAudio = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled) },
|
||||
toggleVideo = { chatModel.callCommand.value = WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled) },
|
||||
dismiss = { withBGApi { chatModel.callManager.endCall(call) } },
|
||||
toggleAudio = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = !call.audioEnabled)) },
|
||||
toggleVideo = { chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Video, enable = !call.videoEnabled)) },
|
||||
toggleSound = {
|
||||
var call = chatModel.activeCall.value
|
||||
if (call != null) {
|
||||
@@ -212,7 +222,7 @@ private fun ActiveCallOverlay(call: Call, chatModel: ChatModel, audioViaBluetoot
|
||||
setCallSound(call.soundSpeaker, audioViaBluetooth)
|
||||
}
|
||||
},
|
||||
flipCamera = { chatModel.callCommand.value = WCallCommand.Camera(call.localCamera.flipped) }
|
||||
flipCamera = { chatModel.callCommand.add(WCallCommand.Camera(call.localCamera.flipped)) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -439,7 +449,7 @@ private fun DisabledBackgroundCallsButton() {
|
||||
//}
|
||||
|
||||
@Composable
|
||||
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
|
||||
fun WebRTCView(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val webView = remember { mutableStateOf<WebView?>(null) }
|
||||
val permissionsState = rememberMultiplePermissionsState(
|
||||
@@ -470,13 +480,19 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
|
||||
webView.value = null
|
||||
}
|
||||
}
|
||||
LaunchedEffect(callCommand.value, webView.value) {
|
||||
val cmd = callCommand.value
|
||||
val wv = webView.value
|
||||
if (cmd != null && wv != null) {
|
||||
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
|
||||
processCommand(wv, cmd)
|
||||
callCommand.value = null
|
||||
val wv = webView.value
|
||||
if (wv != null) {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { callCommand.firstOrNull() }
|
||||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.collect {
|
||||
while (callCommand.isNotEmpty()) {
|
||||
val cmd = callCommand.removeFirst()
|
||||
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
|
||||
processCommand(wv, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val assetLoader = WebViewAssetLoader.Builder()
|
||||
@@ -502,7 +518,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
|
||||
}
|
||||
}
|
||||
}
|
||||
this.webViewClient = LocalContentWebViewClient(assetLoader)
|
||||
this.webViewClient = LocalContentWebViewClient(webView, assetLoader)
|
||||
this.clearHistory()
|
||||
this.clearCache(true)
|
||||
this.addJavascriptInterface(WebRTCInterface(onResponse), "WebRTCInterface")
|
||||
@@ -512,19 +528,10 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
|
||||
webViewSettings.javaScriptEnabled = true
|
||||
webViewSettings.mediaPlaybackRequiresUserGesture = false
|
||||
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
|
||||
this.loadUrl("file:android_asset/www/call.html")
|
||||
this.loadUrl("file:android_asset/www/android/call.html")
|
||||
}
|
||||
}
|
||||
) { wv ->
|
||||
Log.d(TAG, "WebRTCView: webview ready")
|
||||
// for debugging
|
||||
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||
scope.launch {
|
||||
delay(2000L)
|
||||
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||
webView.value = wv
|
||||
}
|
||||
}
|
||||
) { /* WebView */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -539,19 +546,28 @@ class WebRTCInterface(private val onResponse: (WVAPIMessage) -> Unit) {
|
||||
// for debugging
|
||||
// onResponse(message)
|
||||
onResponse(json.decodeFromString(message))
|
||||
} catch (e: Error) {
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "failed parsing WebView message: $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class LocalContentWebViewClient(private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
|
||||
private class LocalContentWebViewClient(val webView: MutableState<WebView?>, private val assetLoader: WebViewAssetLoader) : WebViewClientCompat() {
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView,
|
||||
request: WebResourceRequest
|
||||
): WebResourceResponse? {
|
||||
return assetLoader.shouldInterceptRequest(request.url)
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String) {
|
||||
super.onPageFinished(view, url)
|
||||
view.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
|
||||
webView.value = view
|
||||
Log.d(TAG, "WebRTCView: webview ready")
|
||||
// for debugging
|
||||
// view.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import androidx.compose.runtime.*
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
@Composable
|
||||
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) {}
|
||||
@@ -88,7 +88,7 @@ object ChatModel {
|
||||
val activeCallInvitation = mutableStateOf<RcvCallInvitation?>(null)
|
||||
val activeCall = mutableStateOf<Call?>(null)
|
||||
val activeCallViewIsVisible = mutableStateOf<Boolean>(false)
|
||||
val callCommand = mutableStateOf<WCallCommand?>(null)
|
||||
val callCommand = mutableStateListOf<WCallCommand>()
|
||||
val showCallView = mutableStateOf(false)
|
||||
val switchingCall = mutableStateOf(false)
|
||||
|
||||
|
||||
@@ -1647,25 +1647,25 @@ object ChatController {
|
||||
val useRelay = appPrefs.webrtcPolicyRelay.get()
|
||||
val iceServers = getIceServers()
|
||||
Log.d(TAG, ".callOffer iceServers $iceServers")
|
||||
chatModel.callCommand.value = WCallCommand.Offer(
|
||||
chatModel.callCommand.add(WCallCommand.Offer(
|
||||
offer = r.offer.rtcSession,
|
||||
iceCandidates = r.offer.rtcIceCandidates,
|
||||
media = r.callType.media,
|
||||
aesKey = r.sharedKey,
|
||||
iceServers = iceServers,
|
||||
relay = useRelay
|
||||
)
|
||||
))
|
||||
}
|
||||
}
|
||||
is CR.CallAnswer -> {
|
||||
withCall(r, r.contact) { call ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.AnswerReceived)
|
||||
chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)
|
||||
chatModel.callCommand.add(WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates))
|
||||
}
|
||||
}
|
||||
is CR.CallExtraInfo -> {
|
||||
withCall(r, r.contact) { _ ->
|
||||
chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)
|
||||
chatModel.callCommand.add(WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates))
|
||||
}
|
||||
}
|
||||
is CR.CallEnded -> {
|
||||
@@ -1674,7 +1674,7 @@ object ChatController {
|
||||
chatModel.callManager.reportCallRemoteEnded(invitation = invitation)
|
||||
}
|
||||
withCall(r, r.contact) { _ ->
|
||||
chatModel.callCommand.value = WCallCommand.End
|
||||
chatModel.callCommand.add(WCallCommand.End)
|
||||
withApi {
|
||||
chatModel.activeCall.value = null
|
||||
chatModel.showCallView.value = false
|
||||
|
||||
@@ -3,8 +3,6 @@ package chat.simplex.common.views.call
|
||||
import chat.simplex.common.model.ChatModel
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.helpers.withApi
|
||||
import chat.simplex.common.views.helpers.withBGApi
|
||||
import chat.simplex.common.views.usersettings.showInDevelopingAlert
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
@@ -26,10 +24,6 @@ class CallManager(val chatModel: ChatModel) {
|
||||
}
|
||||
|
||||
fun acceptIncomingCall(invitation: RcvCallInvitation) {
|
||||
if (appPlatform.isDesktop) {
|
||||
return showInDevelopingAlert()
|
||||
}
|
||||
|
||||
val call = chatModel.activeCall.value
|
||||
if (call == null) {
|
||||
justAcceptIncomingCall(invitation = invitation)
|
||||
@@ -58,12 +52,12 @@ class CallManager(val chatModel: ChatModel) {
|
||||
val useRelay = controller.appPrefs.webrtcPolicyRelay.get()
|
||||
val iceServers = getIceServers()
|
||||
Log.d(TAG, "answerIncomingCall iceServers: $iceServers")
|
||||
callCommand.value = WCallCommand.Start(
|
||||
callCommand.add(WCallCommand.Start(
|
||||
media = invitation.callType.media,
|
||||
aesKey = invitation.sharedKey,
|
||||
iceServers = iceServers,
|
||||
relay = useRelay
|
||||
)
|
||||
))
|
||||
callInvitations.remove(invitation.contact.id)
|
||||
if (invitation.contact.id == activeCallInvitation.value?.contact?.id) {
|
||||
activeCallInvitation.value = null
|
||||
@@ -80,7 +74,7 @@ class CallManager(val chatModel: ChatModel) {
|
||||
showCallView.value = false
|
||||
} else {
|
||||
Log.d(TAG, "CallManager.endCall: ending call...")
|
||||
callCommand.value = WCallCommand.End
|
||||
callCommand.add(WCallCommand.End)
|
||||
showCallView.value = false
|
||||
controller.apiEndCall(call.contact)
|
||||
activeCall.value = null
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package chat.simplex.common.views.call
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.icerock.moko.resources.compose.stringResource
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.res.MR
|
||||
@@ -23,16 +21,17 @@ data class Call(
|
||||
val videoEnabled: Boolean = localMedia == CallMediaType.Video,
|
||||
val soundSpeaker: Boolean = localMedia == CallMediaType.Video,
|
||||
var localCamera: VideoCamera = VideoCamera.User,
|
||||
val connectionInfo: ConnectionInfo? = null
|
||||
val connectionInfo: ConnectionInfo? = null,
|
||||
var connectedAt: Instant? = null
|
||||
) {
|
||||
val encrypted: Boolean get() = localEncrypted && sharedKey != null
|
||||
val localEncrypted: Boolean get() = localCapabilities?.encryption ?: false
|
||||
|
||||
val encryptionStatus: String @Composable get() = when(callState) {
|
||||
val encryptionStatus: String get() = when(callState) {
|
||||
CallState.WaitCapabilities -> ""
|
||||
CallState.InvitationSent -> stringResource(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption)
|
||||
CallState.InvitationAccepted -> stringResource(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
|
||||
else -> stringResource(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
|
||||
CallState.InvitationSent -> generalGetString(if (localEncrypted) MR.strings.status_e2e_encrypted else MR.strings.status_no_e2e_encryption)
|
||||
CallState.InvitationAccepted -> generalGetString(if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_contact_has_e2e_encryption)
|
||||
else -> generalGetString(if (!localEncrypted) MR.strings.status_no_e2e_encryption else if (sharedKey == null) MR.strings.status_contact_has_no_e2e_encryption else MR.strings.status_e2e_encrypted)
|
||||
}
|
||||
|
||||
val hasMedia: Boolean get() = callState == CallState.OfferSent || callState == CallState.Negotiated || callState == CallState.Connected
|
||||
@@ -49,16 +48,16 @@ enum class CallState {
|
||||
Connected,
|
||||
Ended;
|
||||
|
||||
val text: String @Composable get() = when(this) {
|
||||
WaitCapabilities -> stringResource(MR.strings.callstate_starting)
|
||||
InvitationSent -> stringResource(MR.strings.callstate_waiting_for_answer)
|
||||
InvitationAccepted -> stringResource(MR.strings.callstate_starting)
|
||||
OfferSent -> stringResource(MR.strings.callstate_waiting_for_confirmation)
|
||||
OfferReceived -> stringResource(MR.strings.callstate_received_answer)
|
||||
AnswerReceived -> stringResource(MR.strings.callstate_received_confirmation)
|
||||
Negotiated -> stringResource(MR.strings.callstate_connecting)
|
||||
Connected -> stringResource(MR.strings.callstate_connected)
|
||||
Ended -> stringResource(MR.strings.callstate_ended)
|
||||
val text: String get() = when(this) {
|
||||
WaitCapabilities -> generalGetString(MR.strings.callstate_starting)
|
||||
InvitationSent -> generalGetString(MR.strings.callstate_waiting_for_answer)
|
||||
InvitationAccepted -> generalGetString(MR.strings.callstate_starting)
|
||||
OfferSent -> generalGetString(MR.strings.callstate_waiting_for_confirmation)
|
||||
OfferReceived -> generalGetString(MR.strings.callstate_received_answer)
|
||||
AnswerReceived -> generalGetString(MR.strings.callstate_received_confirmation)
|
||||
Negotiated -> generalGetString(MR.strings.callstate_connecting)
|
||||
Connected -> generalGetString(MR.strings.callstate_connected)
|
||||
Ended -> generalGetString(MR.strings.callstate_ended)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,13 +66,14 @@ enum class CallState {
|
||||
|
||||
@Serializable
|
||||
sealed class WCallCommand {
|
||||
@Serializable @SerialName("capabilities") object Capabilities: WCallCommand()
|
||||
@Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand()
|
||||
@Serializable @SerialName("start") data class Start(val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("offer") data class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null, val iceServers: List<RTCIceServer>? = null, val relay: Boolean? = null): WCallCommand()
|
||||
@Serializable @SerialName("answer") data class Answer (val answer: String, val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallCommand()
|
||||
@Serializable @SerialName("media") data class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
|
||||
@Serializable @SerialName("camera") data class Camera(val camera: VideoCamera): WCallCommand()
|
||||
@Serializable @SerialName("description") data class Description(val state: String, val description: String): WCallCommand()
|
||||
@Serializable @SerialName("end") object End: WCallCommand()
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ sealed class WCallResponse {
|
||||
@Serializable @SerialName("ice") data class Ice(val iceCandidates: String): WCallResponse()
|
||||
@Serializable @SerialName("connection") data class Connection(val state: ConnectionState): WCallResponse()
|
||||
@Serializable @SerialName("connected") data class Connected(val connectionInfo: ConnectionInfo): WCallResponse()
|
||||
@Serializable @SerialName("end") object End: WCallResponse()
|
||||
@Serializable @SerialName("ended") object Ended: WCallResponse()
|
||||
@Serializable @SerialName("ok") object Ok: WCallResponse()
|
||||
@Serializable @SerialName("error") data class Error(val message: String): WCallResponse()
|
||||
@@ -106,14 +107,14 @@ sealed class WCallResponse {
|
||||
}
|
||||
@Serializable data class CallCapabilities(val encryption: Boolean)
|
||||
@Serializable data class ConnectionInfo(private val localCandidate: RTCIceCandidate?, private val remoteCandidate: RTCIceCandidate?) {
|
||||
val text: String @Composable get() {
|
||||
val text: String get() {
|
||||
val local = localCandidate?.candidateType
|
||||
val remote = remoteCandidate?.candidateType
|
||||
return when {
|
||||
local == RTCIceCandidateType.Host && remote == RTCIceCandidateType.Host ->
|
||||
stringResource(MR.strings.call_connection_peer_to_peer)
|
||||
generalGetString(MR.strings.call_connection_peer_to_peer)
|
||||
local == RTCIceCandidateType.Relay && remote == RTCIceCandidateType.Relay ->
|
||||
stringResource(MR.strings.call_connection_via_relay)
|
||||
generalGetString(MR.strings.call_connection_via_relay)
|
||||
else ->
|
||||
"${local?.value ?: "unknown"} / ${remote?.value ?: "unknown"}"
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.common.model.GroupInfo
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.platform.AudioPlayer
|
||||
import chat.simplex.common.views.usersettings.showInDevelopingAlert
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.*
|
||||
@@ -274,23 +273,24 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
|
||||
withApi { chatModel.controller.apiJoinGroup(groupId) }
|
||||
},
|
||||
startCall = out@ { media ->
|
||||
if (appPlatform.isDesktop) {
|
||||
return@out showInDevelopingAlert()
|
||||
}
|
||||
withBGApi {
|
||||
val cInfo = chat.chatInfo
|
||||
if (cInfo is ChatInfo.Direct) {
|
||||
chatModel.activeCall.value = Call(contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media)
|
||||
chatModel.showCallView.value = true
|
||||
chatModel.callCommand.value = WCallCommand.Capabilities
|
||||
chatModel.callCommand.add(WCallCommand.Capabilities(media))
|
||||
}
|
||||
}
|
||||
},
|
||||
endCall = {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withApi { chatModel.callManager.endCall(call) }
|
||||
},
|
||||
acceptCall = { contact ->
|
||||
hideKeyboard(view)
|
||||
val invitation = chatModel.callInvitations.remove(contact.id)
|
||||
if (invitation == null) {
|
||||
AlertManager.shared.showAlertMsg("Call already ended!")
|
||||
AlertManager.shared.showAlertMsg(generalGetString(MR.strings.call_already_ended))
|
||||
} else {
|
||||
chatModel.callManager.acceptIncomingCall(invitation = invitation)
|
||||
}
|
||||
@@ -433,6 +433,7 @@ fun ChatLayout(
|
||||
cancelFile: (Long) -> Unit,
|
||||
joinGroup: (Long) -> Unit,
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
endCall: () -> Unit,
|
||||
acceptCall: (Contact) -> Unit,
|
||||
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
|
||||
openDirectChat: (Long) -> Unit,
|
||||
@@ -491,7 +492,7 @@ fun ChatLayout(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = { ChatInfoToolbar(chat, back, info, startCall, addMembers, changeNtfsState, onSearchValueChanged) },
|
||||
topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, changeNtfsState, onSearchValueChanged) },
|
||||
bottomBar = composeView,
|
||||
modifier = Modifier.navigationBarsWithImePadding(),
|
||||
floatingActionButton = { floatingButton.value() },
|
||||
@@ -520,6 +521,7 @@ fun ChatInfoToolbar(
|
||||
back: () -> Unit,
|
||||
info: () -> Unit,
|
||||
startCall: (CallMediaType) -> Unit,
|
||||
endCall: () -> Unit,
|
||||
addMembers: (GroupInfo) -> Unit,
|
||||
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
|
||||
onSearchValueChanged: (String) -> Unit,
|
||||
@@ -540,6 +542,7 @@ fun ChatInfoToolbar(
|
||||
}
|
||||
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
|
||||
val menuItems = arrayListOf<@Composable () -> Unit>()
|
||||
val activeCall by remember { chatModel.activeCall }
|
||||
menuItems.add {
|
||||
ItemAction(stringResource(MR.strings.search_verb), painterResource(MR.images.ic_search), onClick = {
|
||||
showMenu.value = false
|
||||
@@ -548,20 +551,52 @@ fun ChatInfoToolbar(
|
||||
}
|
||||
|
||||
if (chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.allowsFeature(ChatFeature.Calls)) {
|
||||
barButtons.add {
|
||||
IconButton({
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Audio)
|
||||
},
|
||||
enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_call_500),
|
||||
stringResource(MR.strings.icon_descr_more_button),
|
||||
tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
)
|
||||
if (activeCall == null) {
|
||||
barButtons.add {
|
||||
IconButton(
|
||||
{
|
||||
showMenu.value = false
|
||||
startCall(CallMediaType.Audio)
|
||||
},
|
||||
enabled = chat.chatInfo.contact.ready && chat.chatInfo.contact.active
|
||||
) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_call_500),
|
||||
stringResource(MR.strings.icon_descr_more_button),
|
||||
tint = if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) MaterialTheme.colors.primary else MaterialTheme.colors.secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (activeCall?.contact?.id == chat.id) {
|
||||
barButtons.add {
|
||||
val call = remember { chatModel.activeCall }.value
|
||||
val connectedAt = call?.connectedAt
|
||||
if (connectedAt != null) {
|
||||
val time = remember { mutableStateOf(durationText(0)) }
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
time.value = durationText((Clock.System.now() - connectedAt).inWholeSeconds.toInt())
|
||||
delay(250)
|
||||
}
|
||||
}
|
||||
val sp50 = with(LocalDensity.current) { 50.sp.toDp() }
|
||||
Text(time.value, Modifier.widthIn(min = sp50))
|
||||
}
|
||||
}
|
||||
barButtons.add {
|
||||
IconButton({
|
||||
showMenu.value = false
|
||||
endCall()
|
||||
}) {
|
||||
Icon(
|
||||
painterResource(MR.images.ic_call_end_filled),
|
||||
null,
|
||||
tint = MaterialTheme.colors.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active) {
|
||||
if (chat.chatInfo.contact.ready && chat.chatInfo.contact.active && activeCall == null) {
|
||||
menuItems.add {
|
||||
ItemAction(stringResource(MR.strings.icon_descr_video_call).capitalize(Locale.current), painterResource(MR.images.ic_videocam), onClick = {
|
||||
showMenu.value = false
|
||||
@@ -1290,6 +1325,7 @@ fun PreviewChatLayout() {
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
endCall = {},
|
||||
acceptCall = { _ -> },
|
||||
acceptFeature = { _, _, _ -> },
|
||||
openDirectChat = { _ -> },
|
||||
@@ -1359,6 +1395,7 @@ fun PreviewGroupChatLayout() {
|
||||
cancelFile = {},
|
||||
joinGroup = {},
|
||||
startCall = {},
|
||||
endCall = {},
|
||||
acceptCall = { _ -> },
|
||||
acceptFeature = { _, _, _ -> },
|
||||
openDirectChat = { _ -> },
|
||||
|
||||
@@ -3,7 +3,6 @@ package chat.simplex.common.views.chat.group
|
||||
import InfoRow
|
||||
import SectionBottomSpacer
|
||||
import SectionDividerSpaced
|
||||
import SectionItemView
|
||||
import SectionSpacer
|
||||
import SectionTextFooter
|
||||
import SectionView
|
||||
@@ -35,7 +34,7 @@ import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.common.views.usersettings.SettingsActionItem
|
||||
import chat.simplex.common.model.GroupInfo
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.chatlist.openChat
|
||||
import chat.simplex.common.views.chatlist.openLoadedChat
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.datetime.Clock
|
||||
|
||||
@@ -87,7 +86,7 @@ fun GroupMemberInfoView(
|
||||
if (memberContact != null) {
|
||||
val memberChat = Chat(ChatInfo.Direct(memberContact), chatItems = arrayListOf())
|
||||
chatModel.addChat(memberChat)
|
||||
openChat(memberChat, chatModel)
|
||||
openLoadedChat(memberChat, chatModel)
|
||||
closeAll()
|
||||
chatModel.setContactNetworkStatus(memberContact, NetworkStatus.Connected())
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
||||
// sharedKey: invitation.sharedKey
|
||||
// )
|
||||
// m.showCallView = true
|
||||
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
|
||||
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true)
|
||||
// } else {
|
||||
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||
// }
|
||||
@@ -141,7 +141,7 @@ fun AcceptCallButton(cInfo: ChatInfo, acceptCall: (Contact) -> Unit) {
|
||||
// sharedKey: invitation.sharedKey
|
||||
// )
|
||||
// m.showCallView = true
|
||||
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey, useWorker: true)
|
||||
// m.callCommand = .start(media: invitation.peerMedia, aesKey: invitation.sharedKey: true)
|
||||
// } else {
|
||||
// AlertManager.shared.showAlertMsg(title: "Call already ended!")
|
||||
// }
|
||||
|
||||
@@ -4,7 +4,6 @@ import SectionItemView
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
@@ -13,10 +12,6 @@ import dev.icerock.moko.resources.compose.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.desktop.ui.tooling.preview.Preview
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.interaction.InteractionSource
|
||||
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.platform.LocalViewConfiguration
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -126,14 +121,14 @@ fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) {
|
||||
suspend fun openDirectChat(contactId: Long, chatModel: ChatModel) {
|
||||
val chat = chatModel.controller.apiGetChat(ChatType.Direct, contactId)
|
||||
if (chat != null) {
|
||||
openChat(chat, chatModel)
|
||||
openLoadedChat(chat, chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun openGroupChat(groupId: Long, chatModel: ChatModel) {
|
||||
val chat = chatModel.controller.apiGetChat(ChatType.Group, groupId)
|
||||
if (chat != null) {
|
||||
openChat(chat, chatModel)
|
||||
openLoadedChat(chat, chatModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,12 +136,12 @@ suspend fun openChat(chatInfo: ChatInfo, chatModel: ChatModel) {
|
||||
Log.d(TAG, "TODOCHAT: openChat: opening ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}")
|
||||
val chat = chatModel.controller.apiGetChat(chatInfo.chatType, chatInfo.apiId)
|
||||
if (chat != null) {
|
||||
openChat(chat, chatModel)
|
||||
openLoadedChat(chat, chatModel)
|
||||
Log.d(TAG, "TODOCHAT: openChat: opened ${chatInfo.id}, current chatId ${ChatModel.chatId.value}, size ${ChatModel.chatItems.size}")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun openChat(chat: Chat, chatModel: ChatModel) {
|
||||
fun openLoadedChat(chat: Chat, chatModel: ChatModel) {
|
||||
chatModel.chatItems.clear()
|
||||
chatModel.chatItemStatuses.clear()
|
||||
chatModel.chatItems.addAll(chat.chatItems)
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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.*
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -29,6 +30,9 @@ import chat.simplex.common.views.onboarding.shouldShowWhatsNew
|
||||
import chat.simplex.common.views.usersettings.SettingsView
|
||||
import chat.simplex.common.views.usersettings.simplexTeamUri
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.views.call.Call
|
||||
import chat.simplex.common.views.call.CallMediaType
|
||||
import chat.simplex.common.views.chat.item.ItemAction
|
||||
import chat.simplex.common.views.newchat.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
@@ -121,6 +125,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
|
||||
}
|
||||
}
|
||||
if (searchInList.isEmpty()) {
|
||||
DesktopActiveCallOverlayLayout(newChatSheetState)
|
||||
NewChatSheet(chatModel, newChatSheetState, stopped, hideNewChatSheet)
|
||||
}
|
||||
if (appPlatform.isAndroid) {
|
||||
@@ -311,6 +316,9 @@ private fun ProgressIndicator() {
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
expect fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>)
|
||||
|
||||
fun connectIfOpenedViaUri(uri: URI, chatModel: ChatModel) {
|
||||
Log.d(TAG, "connectIfOpenedViaUri: opened via link")
|
||||
if (chatModel.currentUser.value == null) {
|
||||
|
||||
@@ -50,11 +50,12 @@ class AlertManager {
|
||||
fun showAlertDialogButtonsColumn(
|
||||
title: String,
|
||||
text: AnnotatedString? = null,
|
||||
onDismissRequest: (() -> Unit)? = null,
|
||||
buttons: @Composable () -> Unit,
|
||||
) {
|
||||
showAlert {
|
||||
AlertDialog(
|
||||
onDismissRequest = ::hideAlert,
|
||||
onDismissRequest = { onDismissRequest?.invoke(); hideAlert() },
|
||||
title = {
|
||||
Text(
|
||||
title,
|
||||
|
||||
@@ -81,6 +81,35 @@ fun ProfileImage(
|
||||
}
|
||||
}
|
||||
|
||||
/** [AccountCircleFilled] has its inner padding which leads to visible border if there is background underneath.
|
||||
* This is workaround
|
||||
* */
|
||||
@Composable
|
||||
fun ProfileImageForActiveCall(
|
||||
size: Dp,
|
||||
image: String? = null,
|
||||
color: Color = MaterialTheme.colors.secondaryVariant,
|
||||
) {
|
||||
if (image == null) {
|
||||
Box(Modifier.requiredSize(size).clip(CircleShape)) {
|
||||
Icon(
|
||||
AccountCircleFilled,
|
||||
contentDescription = stringResource(MR.strings.icon_descr_profile_image_placeholder),
|
||||
tint = color,
|
||||
modifier = Modifier.requiredSize(size + 14.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
val imageBitmap = base64ToBitmap(image)
|
||||
Image(
|
||||
imageBitmap,
|
||||
stringResource(MR.strings.image_descr_profile_image),
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(size).clip(CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# WebView for WebRTC calls in SimpleX Chat
|
||||
|
||||
Do NOT edit call.js here, it is compiled abd copied here from call.ts in packages/simplex-chat-webrtc
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link href="./style.css" rel="stylesheet" />
|
||||
<script src="../lz-string.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<video
|
||||
id="remote-video-stream"
|
||||
autoplay
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
></video>
|
||||
<video
|
||||
id="local-video-stream"
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
></video>
|
||||
</body>
|
||||
<footer>
|
||||
<script src="../call.js"></script>
|
||||
</footer>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#remote-video-stream {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream {
|
||||
position: absolute;
|
||||
width: 30%;
|
||||
max-width: 30%;
|
||||
object-fit: cover;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
*::-webkit-media-controls {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
*::-webkit-media-controls-panel {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
*::-webkit-media-controls-play-button {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
*::-webkit-media-controls-start-playback-button {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link href="./style.css" rel="stylesheet" />
|
||||
<script src="./lz-string.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<video
|
||||
id="remote-video-stream"
|
||||
autoplay
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
></video>
|
||||
<video
|
||||
id="local-video-stream"
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
></video>
|
||||
</body>
|
||||
<footer>
|
||||
<script src="./call.js"></script>
|
||||
</footer>
|
||||
</html>
|
||||
@@ -0,0 +1,703 @@
|
||||
"use strict";
|
||||
// Inspired by
|
||||
// https://github.com/webrtc/samples/blob/gh-pages/src/content/insertable-streams/endtoend-encryption
|
||||
var CallMediaType;
|
||||
(function (CallMediaType) {
|
||||
CallMediaType["Audio"] = "audio";
|
||||
CallMediaType["Video"] = "video";
|
||||
})(CallMediaType || (CallMediaType = {}));
|
||||
var VideoCamera;
|
||||
(function (VideoCamera) {
|
||||
VideoCamera["User"] = "user";
|
||||
VideoCamera["Environment"] = "environment";
|
||||
})(VideoCamera || (VideoCamera = {}));
|
||||
// for debugging
|
||||
// var sendMessageToNative = ({resp}: WVApiMessage) => console.log(JSON.stringify({command: resp}))
|
||||
var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
|
||||
// Global object with cryptrographic/encoding functions
|
||||
const callCrypto = callCryptoFunction();
|
||||
var TransformOperation;
|
||||
(function (TransformOperation) {
|
||||
TransformOperation["Encrypt"] = "encrypt";
|
||||
TransformOperation["Decrypt"] = "decrypt";
|
||||
})(TransformOperation || (TransformOperation = {}));
|
||||
let activeCall;
|
||||
let answerTimeout = 30000;
|
||||
var useWorker = false;
|
||||
var localizedState = "";
|
||||
var localizedDescription = "";
|
||||
const processCommand = (function () {
|
||||
const defaultIceServers = [
|
||||
{ urls: ["stun:stun.simplex.im:443"] },
|
||||
{ urls: ["turn:turn.simplex.im:443?transport=udp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||
{ urls: ["turn:turn.simplex.im:443?transport=tcp"], username: "private", credential: "yleob6AVkiNI87hpR94Z" },
|
||||
];
|
||||
function getCallConfig(encodedInsertableStreams, iceServers, relay) {
|
||||
return {
|
||||
peerConnectionConfig: {
|
||||
iceServers: iceServers !== null && iceServers !== void 0 ? iceServers : defaultIceServers,
|
||||
iceCandidatePoolSize: 10,
|
||||
encodedInsertableStreams,
|
||||
iceTransportPolicy: relay ? "relay" : "all",
|
||||
},
|
||||
iceCandidates: {
|
||||
delay: 750,
|
||||
extrasInterval: 1500,
|
||||
extrasTimeout: 12000,
|
||||
},
|
||||
};
|
||||
}
|
||||
function getIceCandidates(conn, config) {
|
||||
return new Promise((resolve, _) => {
|
||||
let candidates = [];
|
||||
let resolved = false;
|
||||
let extrasInterval;
|
||||
let extrasTimeout;
|
||||
const delay = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolveIceCandidates();
|
||||
extrasInterval = setInterval(() => {
|
||||
sendIceCandidates();
|
||||
}, config.iceCandidates.extrasInterval);
|
||||
extrasTimeout = setTimeout(() => {
|
||||
clearInterval(extrasInterval);
|
||||
sendIceCandidates();
|
||||
}, config.iceCandidates.extrasTimeout);
|
||||
}
|
||||
}, config.iceCandidates.delay);
|
||||
conn.onicecandidate = ({ candidate: c }) => c && candidates.push(c);
|
||||
conn.onicegatheringstatechange = () => {
|
||||
if (conn.iceGatheringState == "complete") {
|
||||
if (resolved) {
|
||||
if (extrasInterval)
|
||||
clearInterval(extrasInterval);
|
||||
if (extrasTimeout)
|
||||
clearTimeout(extrasTimeout);
|
||||
sendIceCandidates();
|
||||
}
|
||||
else {
|
||||
resolveIceCandidates();
|
||||
}
|
||||
}
|
||||
};
|
||||
function resolveIceCandidates() {
|
||||
if (delay)
|
||||
clearTimeout(delay);
|
||||
resolved = true;
|
||||
// console.log("resolveIceCandidates", JSON.stringify(candidates))
|
||||
console.log("resolveIceCandidates");
|
||||
const iceCandidates = serialize(candidates);
|
||||
candidates = [];
|
||||
resolve(iceCandidates);
|
||||
}
|
||||
function sendIceCandidates() {
|
||||
if (candidates.length === 0)
|
||||
return;
|
||||
// console.log("sendIceCandidates", JSON.stringify(candidates))
|
||||
console.log("sendIceCandidates");
|
||||
const iceCandidates = serialize(candidates);
|
||||
candidates = [];
|
||||
sendMessageToNative({ resp: { type: "ice", iceCandidates } });
|
||||
}
|
||||
});
|
||||
}
|
||||
async function initializeCall(config, mediaType, aesKey) {
|
||||
const pc = new RTCPeerConnection(config.peerConnectionConfig);
|
||||
const remoteStream = new MediaStream();
|
||||
const localCamera = VideoCamera.User;
|
||||
const localStream = await getLocalMediaStream(mediaType, localCamera);
|
||||
const iceCandidates = getIceCandidates(pc, config);
|
||||
const call = { connection: pc, iceCandidates, localMedia: mediaType, localCamera, localStream, remoteStream, aesKey };
|
||||
await setupMediaStreams(call);
|
||||
let connectionTimeout = setTimeout(connectionHandler, answerTimeout);
|
||||
pc.addEventListener("connectionstatechange", connectionStateChange);
|
||||
return call;
|
||||
async function connectionStateChange() {
|
||||
// "failed" means the second party did not answer in time (15 sec timeout in Chrome WebView)
|
||||
// See https://source.chromium.org/chromium/chromium/src/+/main:third_party/webrtc/p2p/base/p2p_constants.cc;l=70)
|
||||
if (pc.connectionState !== "failed")
|
||||
connectionHandler();
|
||||
}
|
||||
async function connectionHandler() {
|
||||
sendMessageToNative({
|
||||
resp: {
|
||||
type: "connection",
|
||||
state: {
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
iceGatheringState: pc.iceGatheringState,
|
||||
signalingState: pc.signalingState,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (pc.connectionState == "disconnected" || pc.connectionState == "failed") {
|
||||
clearConnectionTimeout();
|
||||
pc.removeEventListener("connectionstatechange", connectionStateChange);
|
||||
if (activeCall) {
|
||||
setTimeout(() => sendMessageToNative({ resp: { type: "ended" } }), 0);
|
||||
}
|
||||
endCall();
|
||||
}
|
||||
else if (pc.connectionState == "connected") {
|
||||
clearConnectionTimeout();
|
||||
const stats = (await pc.getStats());
|
||||
for (const stat of stats.values()) {
|
||||
const { type, state } = stat;
|
||||
if (type === "candidate-pair" && state === "succeeded") {
|
||||
const iceCandidatePair = stat;
|
||||
const resp = {
|
||||
type: "connected",
|
||||
connectionInfo: {
|
||||
iceCandidatePair,
|
||||
localCandidate: stats.get(iceCandidatePair.localCandidateId),
|
||||
remoteCandidate: stats.get(iceCandidatePair.remoteCandidateId),
|
||||
},
|
||||
};
|
||||
setTimeout(() => sendMessageToNative({ resp }), 500);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function clearConnectionTimeout() {
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout);
|
||||
connectionTimeout = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
function serialize(x) {
|
||||
return LZString.compressToBase64(JSON.stringify(x));
|
||||
}
|
||||
function parse(s) {
|
||||
return JSON.parse(LZString.decompressFromBase64(s));
|
||||
}
|
||||
async function processCommand(body) {
|
||||
const { corrId, command } = body;
|
||||
const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection;
|
||||
let resp;
|
||||
try {
|
||||
switch (command.type) {
|
||||
case "capabilities":
|
||||
console.log("starting outgoing call - capabilities");
|
||||
if (activeCall)
|
||||
endCall();
|
||||
// This request for local media stream is made to prompt for camera/mic permissions on call start
|
||||
if (command.media)
|
||||
await getLocalMediaStream(command.media, VideoCamera.User);
|
||||
const encryption = supportsInsertableStreams(useWorker);
|
||||
resp = { type: "capabilities", capabilities: { encryption } };
|
||||
break;
|
||||
case "start": {
|
||||
console.log("starting incoming call - create webrtc session");
|
||||
if (activeCall)
|
||||
endCall();
|
||||
const { media, iceServers, relay } = command;
|
||||
const encryption = supportsInsertableStreams(useWorker);
|
||||
const aesKey = encryption ? command.aesKey : undefined;
|
||||
activeCall = await initializeCall(getCallConfig(encryption && !!aesKey, iceServers, relay), media, aesKey);
|
||||
const pc = activeCall.connection;
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
// for debugging, returning the command for callee to use
|
||||
// resp = {
|
||||
// type: "offer",
|
||||
// offer: serialize(offer),
|
||||
// iceCandidates: await activeCall.iceCandidates,
|
||||
// capabilities: {encryption},
|
||||
// media,
|
||||
// iceServers,
|
||||
// relay,
|
||||
// aesKey,
|
||||
// }
|
||||
resp = {
|
||||
type: "offer",
|
||||
offer: serialize(offer),
|
||||
iceCandidates: await activeCall.iceCandidates,
|
||||
capabilities: { encryption },
|
||||
};
|
||||
// console.log("offer response", JSON.stringify(resp))
|
||||
break;
|
||||
}
|
||||
case "offer":
|
||||
if (activeCall) {
|
||||
resp = { type: "error", message: "accept: call already started" };
|
||||
}
|
||||
else if (!supportsInsertableStreams(useWorker) && command.aesKey) {
|
||||
resp = { type: "error", message: "accept: encryption is not supported" };
|
||||
}
|
||||
else {
|
||||
const offer = parse(command.offer);
|
||||
const remoteIceCandidates = parse(command.iceCandidates);
|
||||
const { media, aesKey, iceServers, relay } = command;
|
||||
activeCall = await initializeCall(getCallConfig(!!aesKey, iceServers, relay), media, aesKey);
|
||||
const pc = activeCall.connection;
|
||||
// console.log("offer remoteIceCandidates", JSON.stringify(remoteIceCandidates))
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
||||
const answer = await pc.createAnswer();
|
||||
await pc.setLocalDescription(answer);
|
||||
addIceCandidates(pc, remoteIceCandidates);
|
||||
// same as command for caller to use
|
||||
resp = {
|
||||
type: "answer",
|
||||
answer: serialize(answer),
|
||||
iceCandidates: await activeCall.iceCandidates,
|
||||
};
|
||||
}
|
||||
// console.log("answer response", JSON.stringify(resp))
|
||||
break;
|
||||
case "answer":
|
||||
if (!pc) {
|
||||
resp = { type: "error", message: "answer: call not started" };
|
||||
}
|
||||
else if (!pc.localDescription) {
|
||||
resp = { type: "error", message: "answer: local description is not set" };
|
||||
}
|
||||
else if (pc.currentRemoteDescription) {
|
||||
resp = { type: "error", message: "answer: remote description already set" };
|
||||
}
|
||||
else {
|
||||
const answer = parse(command.answer);
|
||||
const remoteIceCandidates = parse(command.iceCandidates);
|
||||
// console.log("answer remoteIceCandidates", JSON.stringify(remoteIceCandidates))
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(answer));
|
||||
addIceCandidates(pc, remoteIceCandidates);
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
break;
|
||||
case "ice":
|
||||
if (pc) {
|
||||
const remoteIceCandidates = parse(command.iceCandidates);
|
||||
addIceCandidates(pc, remoteIceCandidates);
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
else {
|
||||
resp = { type: "error", message: "ice: call not started" };
|
||||
}
|
||||
break;
|
||||
case "media":
|
||||
if (!activeCall) {
|
||||
resp = { type: "error", message: "media: call not started" };
|
||||
}
|
||||
else if (activeCall.localMedia == CallMediaType.Audio && command.media == CallMediaType.Video) {
|
||||
resp = { type: "error", message: "media: no video" };
|
||||
}
|
||||
else {
|
||||
enableMedia(activeCall.localStream, command.media, command.enable);
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
break;
|
||||
case "camera":
|
||||
if (!activeCall || !pc) {
|
||||
resp = { type: "error", message: "camera: call not started" };
|
||||
}
|
||||
else {
|
||||
await replaceMedia(activeCall, command.camera);
|
||||
resp = { type: "ok" };
|
||||
}
|
||||
break;
|
||||
case "description":
|
||||
localizedState = command.state;
|
||||
localizedDescription = command.description;
|
||||
resp = { type: "ok" };
|
||||
break;
|
||||
case "end":
|
||||
endCall();
|
||||
resp = { type: "ok" };
|
||||
break;
|
||||
default:
|
||||
resp = { type: "error", message: "unknown command" };
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
resp = { type: "error", message: `${command.type}: ${e.message}` };
|
||||
}
|
||||
const apiResp = { corrId, resp, command };
|
||||
sendMessageToNative(apiResp);
|
||||
return apiResp;
|
||||
}
|
||||
function endCall() {
|
||||
var _a;
|
||||
try {
|
||||
(_a = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection) === null || _a === void 0 ? void 0 : _a.close();
|
||||
}
|
||||
catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
shutdownCameraAndMic();
|
||||
activeCall = undefined;
|
||||
resetVideoElements();
|
||||
}
|
||||
function addIceCandidates(conn, iceCandidates) {
|
||||
for (const c of iceCandidates) {
|
||||
conn.addIceCandidate(new RTCIceCandidate(c));
|
||||
// console.log("addIceCandidates", JSON.stringify(c))
|
||||
}
|
||||
}
|
||||
async function setupMediaStreams(call) {
|
||||
const videos = getVideoElements();
|
||||
if (!videos)
|
||||
throw Error("no video elements");
|
||||
await setupEncryptionWorker(call);
|
||||
setupLocalStream(call);
|
||||
setupRemoteStream(call);
|
||||
setupCodecPreferences(call);
|
||||
// setupVideoElement(videos.local)
|
||||
// setupVideoElement(videos.remote)
|
||||
videos.local.srcObject = call.localStream;
|
||||
videos.remote.srcObject = call.remoteStream;
|
||||
}
|
||||
async function setupEncryptionWorker(call) {
|
||||
if (call.aesKey) {
|
||||
if (!call.key)
|
||||
call.key = await callCrypto.decodeAesKey(call.aesKey);
|
||||
if (useWorker && !call.worker) {
|
||||
const workerCode = `const callCrypto = (${callCryptoFunction.toString()})(); (${workerFunction.toString()})()`;
|
||||
call.worker = new Worker(URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" })));
|
||||
call.worker.onerror = ({ error, filename, lineno, message }) => console.log({ error, filename, lineno, message });
|
||||
// call.worker.onmessage = ({data}) => console.log(JSON.stringify({message: data}))
|
||||
}
|
||||
}
|
||||
}
|
||||
function setupLocalStream(call) {
|
||||
const videos = getVideoElements();
|
||||
if (!videos)
|
||||
throw Error("no video elements");
|
||||
const pc = call.connection;
|
||||
let { localStream } = call;
|
||||
for (const track of localStream.getTracks()) {
|
||||
pc.addTrack(track, localStream);
|
||||
}
|
||||
if (call.aesKey && call.key) {
|
||||
console.log("set up encryption for sending");
|
||||
for (const sender of pc.getSenders()) {
|
||||
setupPeerTransform(TransformOperation.Encrypt, sender, call.worker, call.aesKey, call.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
function setupRemoteStream(call) {
|
||||
// Pull tracks from remote stream as they arrive add them to remoteStream video
|
||||
const pc = call.connection;
|
||||
pc.ontrack = (event) => {
|
||||
try {
|
||||
if (call.aesKey && call.key) {
|
||||
console.log("set up decryption for receiving");
|
||||
setupPeerTransform(TransformOperation.Decrypt, event.receiver, call.worker, call.aesKey, call.key);
|
||||
}
|
||||
for (const stream of event.streams) {
|
||||
for (const track of stream.getTracks()) {
|
||||
call.remoteStream.addTrack(track);
|
||||
}
|
||||
}
|
||||
console.log(`ontrack success`);
|
||||
}
|
||||
catch (e) {
|
||||
console.log(`ontrack error: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
function setupCodecPreferences(call) {
|
||||
// We assume VP8 encoding in the decode/encode stages to get the initial
|
||||
// bytes to pass as plaintext so we enforce that here.
|
||||
// VP8 is supported by all supports of webrtc.
|
||||
// Use of VP8 by default may also reduce depacketisation issues.
|
||||
// We do not encrypt the first couple of bytes of the payload so that the
|
||||
// video elements can work by determining video keyframes and the opus mode
|
||||
// being used. This appears to be necessary for any video feed at all.
|
||||
// For VP8 this is the content described in
|
||||
// https://tools.ietf.org/html/rfc6386#section-9.1
|
||||
// which is 10 bytes for key frames and 3 bytes for delta frames.
|
||||
// For opus (where encodedFrame.type is not set) this is the TOC byte from
|
||||
// https://tools.ietf.org/html/rfc6716#section-3.1
|
||||
var _a;
|
||||
const capabilities = RTCRtpSender.getCapabilities("video");
|
||||
if (capabilities) {
|
||||
const { codecs } = capabilities;
|
||||
const selectedCodecIndex = codecs.findIndex((c) => c.mimeType === "video/VP8");
|
||||
const selectedCodec = codecs[selectedCodecIndex];
|
||||
codecs.splice(selectedCodecIndex, 1);
|
||||
codecs.unshift(selectedCodec);
|
||||
for (const t of call.connection.getTransceivers()) {
|
||||
if (((_a = t.sender.track) === null || _a === void 0 ? void 0 : _a.kind) === "video") {
|
||||
t.setCodecPreferences(codecs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
async function replaceMedia(call, camera) {
|
||||
const videos = getVideoElements();
|
||||
if (!videos)
|
||||
throw Error("no video elements");
|
||||
const pc = call.connection;
|
||||
for (const t of call.localStream.getTracks())
|
||||
t.stop();
|
||||
call.localCamera = camera;
|
||||
const localStream = await getLocalMediaStream(call.localMedia, camera);
|
||||
replaceTracks(pc, localStream.getVideoTracks());
|
||||
replaceTracks(pc, localStream.getAudioTracks());
|
||||
call.localStream = localStream;
|
||||
videos.local.srcObject = localStream;
|
||||
}
|
||||
function replaceTracks(pc, tracks) {
|
||||
if (!tracks.length)
|
||||
return;
|
||||
const sender = pc.getSenders().find((s) => { var _a; return ((_a = s.track) === null || _a === void 0 ? void 0 : _a.kind) === tracks[0].kind; });
|
||||
if (sender)
|
||||
for (const t of tracks)
|
||||
sender.replaceTrack(t);
|
||||
}
|
||||
function setupPeerTransform(operation, peer, worker, aesKey, key) {
|
||||
if (worker && "RTCRtpScriptTransform" in window) {
|
||||
console.log(`${operation} with worker & RTCRtpScriptTransform`);
|
||||
peer.transform = new RTCRtpScriptTransform(worker, { operation, aesKey });
|
||||
}
|
||||
else if ("createEncodedStreams" in peer) {
|
||||
const { readable, writable } = peer.createEncodedStreams();
|
||||
if (worker) {
|
||||
console.log(`${operation} with worker`);
|
||||
worker.postMessage({ operation, readable, writable, aesKey }, [readable, writable]);
|
||||
}
|
||||
else {
|
||||
console.log(`${operation} without worker`);
|
||||
const transform = callCrypto.transformFrame[operation](key);
|
||||
readable.pipeThrough(new TransformStream({ transform })).pipeTo(writable);
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log(`no ${operation}`);
|
||||
}
|
||||
}
|
||||
function getLocalMediaStream(mediaType, facingMode) {
|
||||
const constraints = callMediaConstraints(mediaType, facingMode);
|
||||
return navigator.mediaDevices.getUserMedia(constraints);
|
||||
}
|
||||
function callMediaConstraints(mediaType, facingMode) {
|
||||
switch (mediaType) {
|
||||
case CallMediaType.Audio:
|
||||
return { audio: true, video: false };
|
||||
case CallMediaType.Video:
|
||||
return {
|
||||
audio: true,
|
||||
video: {
|
||||
frameRate: 24,
|
||||
width: {
|
||||
min: 480,
|
||||
ideal: 720,
|
||||
max: 1280,
|
||||
},
|
||||
aspectRatio: 1.33,
|
||||
facingMode,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
function supportsInsertableStreams(useWorker) {
|
||||
return (("createEncodedStreams" in RTCRtpSender.prototype && "createEncodedStreams" in RTCRtpReceiver.prototype) ||
|
||||
(!!useWorker && "RTCRtpScriptTransform" in window));
|
||||
}
|
||||
function shutdownCameraAndMic() {
|
||||
if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localStream) {
|
||||
activeCall.localStream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
}
|
||||
function resetVideoElements() {
|
||||
const videos = getVideoElements();
|
||||
if (!videos)
|
||||
return;
|
||||
videos.local.srcObject = null;
|
||||
videos.remote.srcObject = null;
|
||||
}
|
||||
function getVideoElements() {
|
||||
const local = document.getElementById("local-video-stream");
|
||||
const remote = document.getElementById("remote-video-stream");
|
||||
if (!(local && remote && local instanceof HTMLMediaElement && remote instanceof HTMLMediaElement))
|
||||
return;
|
||||
return { local, remote };
|
||||
}
|
||||
// function setupVideoElement(video: HTMLElement) {
|
||||
// // TODO use display: none
|
||||
// video.style.opacity = "0"
|
||||
// video.onplaying = () => {
|
||||
// video.style.opacity = "1"
|
||||
// }
|
||||
// }
|
||||
function enableMedia(s, media, enable) {
|
||||
const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks();
|
||||
for (const t of tracks)
|
||||
t.enabled = enable;
|
||||
}
|
||||
return processCommand;
|
||||
})();
|
||||
function toggleMedia(s, media) {
|
||||
let res = false;
|
||||
const tracks = media == CallMediaType.Video ? s.getVideoTracks() : s.getAudioTracks();
|
||||
for (const t of tracks) {
|
||||
t.enabled = !t.enabled;
|
||||
res = t.enabled;
|
||||
}
|
||||
return res;
|
||||
}
|
||||
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
|
||||
function callCryptoFunction() {
|
||||
const initialPlainTextRequired = {
|
||||
key: 10,
|
||||
delta: 3,
|
||||
empty: 1,
|
||||
};
|
||||
const IV_LENGTH = 12;
|
||||
function encryptFrame(key) {
|
||||
return async (frame, controller) => {
|
||||
const data = new Uint8Array(frame.data);
|
||||
const n = initialPlainTextRequired[frame.type] || 1;
|
||||
const iv = randomIV();
|
||||
const initial = data.subarray(0, n);
|
||||
const plaintext = data.subarray(n, data.byteLength);
|
||||
try {
|
||||
const ciphertext = plaintext.length
|
||||
? new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: iv.buffer }, key, plaintext))
|
||||
: new Uint8Array(0);
|
||||
frame.data = concatN(initial, ciphertext, iv).buffer;
|
||||
controller.enqueue(frame);
|
||||
}
|
||||
catch (e) {
|
||||
console.log(`encryption error ${e}`);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
function decryptFrame(key) {
|
||||
return async (frame, controller) => {
|
||||
const data = new Uint8Array(frame.data);
|
||||
const n = initialPlainTextRequired[frame.type] || 1;
|
||||
const initial = data.subarray(0, n);
|
||||
const ciphertext = data.subarray(n, data.byteLength - IV_LENGTH);
|
||||
const iv = data.subarray(data.byteLength - IV_LENGTH, data.byteLength);
|
||||
try {
|
||||
const plaintext = ciphertext.length
|
||||
? new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ciphertext))
|
||||
: new Uint8Array(0);
|
||||
frame.data = concatN(initial, plaintext).buffer;
|
||||
controller.enqueue(frame);
|
||||
}
|
||||
catch (e) {
|
||||
console.log(`decryption error ${e}`);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
function decodeAesKey(aesKey) {
|
||||
const keyData = callCrypto.decodeBase64url(callCrypto.encodeAscii(aesKey));
|
||||
return crypto.subtle.importKey("raw", keyData, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
|
||||
}
|
||||
function concatN(...bs) {
|
||||
const a = new Uint8Array(bs.reduce((size, b) => size + b.byteLength, 0));
|
||||
bs.reduce((offset, b) => {
|
||||
a.set(b, offset);
|
||||
return offset + b.byteLength;
|
||||
}, 0);
|
||||
return a;
|
||||
}
|
||||
function randomIV() {
|
||||
return crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||
}
|
||||
const base64urlChars = new Uint8Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".split("").map((c) => c.charCodeAt(0)));
|
||||
const base64urlLookup = new Array(256);
|
||||
base64urlChars.forEach((c, i) => (base64urlLookup[c] = i));
|
||||
const char_equal = "=".charCodeAt(0);
|
||||
function encodeAscii(s) {
|
||||
const a = new Uint8Array(s.length);
|
||||
let i = s.length;
|
||||
while (i--)
|
||||
a[i] = s.charCodeAt(i);
|
||||
return a;
|
||||
}
|
||||
function decodeAscii(a) {
|
||||
let s = "";
|
||||
for (let i = 0; i < a.length; i++)
|
||||
s += String.fromCharCode(a[i]);
|
||||
return s;
|
||||
}
|
||||
function encodeBase64url(a) {
|
||||
const len = a.length;
|
||||
const b64len = Math.ceil(len / 3) * 4;
|
||||
const b64 = new Uint8Array(b64len);
|
||||
let j = 0;
|
||||
for (let i = 0; i < len; i += 3) {
|
||||
b64[j++] = base64urlChars[a[i] >> 2];
|
||||
b64[j++] = base64urlChars[((a[i] & 3) << 4) | (a[i + 1] >> 4)];
|
||||
b64[j++] = base64urlChars[((a[i + 1] & 15) << 2) | (a[i + 2] >> 6)];
|
||||
b64[j++] = base64urlChars[a[i + 2] & 63];
|
||||
}
|
||||
if (len % 3)
|
||||
b64[b64len - 1] = char_equal;
|
||||
if (len % 3 === 1)
|
||||
b64[b64len - 2] = char_equal;
|
||||
return b64;
|
||||
}
|
||||
function decodeBase64url(b64) {
|
||||
let len = b64.length;
|
||||
if (len % 4)
|
||||
return;
|
||||
let bLen = (len * 3) / 4;
|
||||
if (b64[len - 1] === char_equal) {
|
||||
len--;
|
||||
bLen--;
|
||||
if (b64[len - 1] === char_equal) {
|
||||
len--;
|
||||
bLen--;
|
||||
}
|
||||
}
|
||||
const bytes = new Uint8Array(bLen);
|
||||
let i = 0;
|
||||
let pos = 0;
|
||||
while (i < len) {
|
||||
const enc1 = base64urlLookup[b64[i++]];
|
||||
const enc2 = i < len ? base64urlLookup[b64[i++]] : 0;
|
||||
const enc3 = i < len ? base64urlLookup[b64[i++]] : 0;
|
||||
const enc4 = i < len ? base64urlLookup[b64[i++]] : 0;
|
||||
if (enc1 === undefined || enc2 === undefined || enc3 === undefined || enc4 === undefined)
|
||||
return;
|
||||
bytes[pos++] = (enc1 << 2) | (enc2 >> 4);
|
||||
bytes[pos++] = ((enc2 & 15) << 4) | (enc3 >> 2);
|
||||
bytes[pos++] = ((enc3 & 3) << 6) | (enc4 & 63);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
return {
|
||||
transformFrame: { encrypt: encryptFrame, decrypt: decryptFrame },
|
||||
decodeAesKey,
|
||||
encodeAscii,
|
||||
decodeAscii,
|
||||
encodeBase64url,
|
||||
decodeBase64url,
|
||||
};
|
||||
}
|
||||
// If the worker is used for decryption, this function code (as string) is used to load the worker via Blob
|
||||
// We have to use worker optionally, as it crashes in Android web view, regardless of how it is loaded
|
||||
function workerFunction() {
|
||||
// encryption with createEncodedStreams support
|
||||
self.addEventListener("message", async ({ data }) => {
|
||||
await setupTransform(data);
|
||||
});
|
||||
// encryption using RTCRtpScriptTransform.
|
||||
if ("RTCTransformEvent" in self) {
|
||||
self.addEventListener("rtctransform", async ({ transformer }) => {
|
||||
try {
|
||||
const { operation, aesKey } = transformer.options;
|
||||
const { readable, writable } = transformer;
|
||||
await setupTransform({ operation, aesKey, readable, writable });
|
||||
self.postMessage({ result: "setupTransform success" });
|
||||
}
|
||||
catch (e) {
|
||||
self.postMessage({ message: `setupTransform error: ${e.message}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
async function setupTransform({ operation, aesKey, readable, writable }) {
|
||||
const key = await callCrypto.decodeAesKey(aesKey);
|
||||
const transform = callCrypto.transformFrame[operation](key);
|
||||
readable.pipeThrough(new TransformStream({ transform })).pipeTo(writable);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=call.js.map
|
||||
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SimpleX Chat WebRTC call</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<link href="/desktop/style.css" rel="stylesheet" />
|
||||
<script src="/lz-string.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<video
|
||||
id="remote-video-stream"
|
||||
autoplay
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
></video>
|
||||
<video
|
||||
id="local-video-stream"
|
||||
muted
|
||||
autoplay
|
||||
playsinline
|
||||
poster="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAEUlEQVR42mNk+M+AARiHsiAAcCIKAYwFoQ8AAAAASUVORK5CYII="
|
||||
></video>
|
||||
<div id="progress"></div>
|
||||
<div id="info-block">
|
||||
<p id="state"></p>
|
||||
<p id="description"></p>
|
||||
</div>
|
||||
<div id="audio-call-icon">
|
||||
<img src="/desktop/images/ic_phone_in_talk.svg" />
|
||||
</div>
|
||||
<p id="manage-call">
|
||||
<button id="toggle-audio" style="display: none" onclick="javascript:toggleAudioManually()">
|
||||
<img src="/desktop/images/ic_mic.svg" />
|
||||
</button>
|
||||
<button id="end-call" onclick="javascript:endCallManually()">
|
||||
<img src="/desktop/images/ic_call_end_filled.svg" />
|
||||
</button>
|
||||
<button id="toggle-speaker" style="display: none" onclick="javascript:toggleSpeakerManually()">
|
||||
<img src="/desktop/images/ic_volume_up.svg" />
|
||||
</button>
|
||||
<button id="toggle-video" style="display: none" onclick="javascript:toggleVideoManually()">
|
||||
<img src="/desktop/images/ic_videocam_filled.svg" />
|
||||
</button>
|
||||
</p>
|
||||
</body>
|
||||
<footer>
|
||||
<script src="/call.js"></script>
|
||||
<script src="/desktop/ui.js"></script>
|
||||
</footer>
|
||||
</html>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="red" d="M480 418q125 0 238.75 50.25T914 613.5q8 9.5 8.25 21t-8.25 20L821 748q-8 8-22.5 8.75t-23-5.75l-113-84.5q-6-4.5-8.75-10.25T651 643.5v-139q-42-16-85.5-22.5t-85-6.5q-42 0-85.5 6.5t-85.5 22.5v139q0 6.5-2.75 12.5T298 666.5L184.5 751q-11.5 8.5-23.5 7.5T139.5 748L46 654.5q-8.5-8.5-8.25-20t8.25-21q81.5-95 195.25-145.25T480 418Z"/></svg>
|
||||
|
After Width: | Height: | Size: 435 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M480 630.5q-41.75 0-69.875-30.167Q382 570.167 382 527V278q0-40.417 28.566-68.708Q439.132 181 479.941 181t69.434 28.292Q578 237.583 578 278v249q0 43.167-28.125 73.333Q521.75 630.5 480 630.5Zm0-224.5Zm-.175 526q-12.325 0-20.325-8.375t-8-20.625V795.865Q354 786 285.25 719T206 557.5q-1.5-12.593 7.295-21.547Q222.091 527 235.5 527q9.917 0 18.148 7.542 8.232 7.541 9.852 18.458 10.5 80.5 72.044 134 61.543 53.5 144.347 53.5 82.805 0 144.457-53.5Q686 633.5 696.5 553q1.853-11.167 10.121-18.583Q714.89 527 725.543 527q12.91 0 21.434 8.953Q755.5 544.907 754 557.5 743.5 652 674.75 719T509 795.865V903q0 12.25-8.425 20.625-8.426 8.375-20.75 8.375ZM480 573q18.075 0 29.288-13.5Q520.5 546 520.5 527V278.335q0-16.835-11.629-28.335-11.628-11.5-28.818-11.5t-28.872 11.356Q439.5 261.212 439.5 278v248.868q0 19.132 11.212 32.632Q461.925 573 480 573Z"/></svg>
|
||||
|
After Width: | Height: | Size: 949 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M681.5 693 640 651.5q16.5-20.5 26-45.75T679 553q1.814-11.167 10.182-18.583Q697.551 527 707.847 527q13.153 0 21.653 8.953 8.5 8.954 7 21.547-4.5 37-18.5 71.75T681.5 693ZM554 566l-51-50V279.038q0-17.463-11.489-29.001-11.49-11.537-29.213-11.537t-29.01 11.431Q422 261.362 422 279v155l-57.5-57.5V279q0-40.833 28.515-69.417Q421.529 181 462.265 181q40.735 0 69.485 28.583Q560.5 238.167 560.5 279v248.23q0 7.103-1.5 19.186-1.5 12.084-5 19.584Zm-94.5-94.5Zm350.5 505L58.5 225q-8-7.444-8-18.222Q50.5 196 58.25 188q7.75-8 18.006-8 10.255 0 18.244 8L847 940.5q8 7.989 8 17.994 0 10.006-8 17.756-8 8.25-18.961 8.25-10.961 0-18.039-8ZM433.5 903V795.865Q336 786 267.5 719t-79-161.5q-2-12.5 7.045-21.5 9.046-9 22.455-9 9.5 0 17.75 7.5T246 553q10.053 80.713 71.588 134.107Q379.124 740.5 462.289 740.5q37.711 0 73.071-12.588Q570.721 715.325 599.5 693l41.5 41.5q-31 26-69.014 41.568Q533.972 791.635 491 796v107q0 12.25-8.463 20.625T462.325 932q-12.325 0-20.575-8.375T433.5 903Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="100" viewBox="0 -960 960 960" width="100"><path fill="white" d="M774.5-488.5q-5.5-119.5-89-203.25t-203-88.75V-838q71 2.5 133.5 30.5t109.75 75.25q47.25 47.25 75.5 110T832-488.5h-57.5Zm-168 0q-6-49.5-40.5-83.75t-83.5-39.25V-669q73 5 124.25 56T664-488.5h-57.5Zm184 363.5Q677-125 558-180.5T338-338Q236-439 180.5-557.75T125-790.692q0-18.808 12.714-31.558Q150.429-835 169.5-835H306q14 0 23.75 9.75t13.75 24.75l26.929 123.641Q372-663.5 369.5-652q-2.5 11.5-10.229 19.226L261-533q26 44 54.688 81.658Q344.375-413.683 379-380q36.5 38 77.25 69.323Q497-279.353 542-255l95.544-98q9.456-10.5 21.357-14.25T682.5-369l117.362 25.438Q815-340 825-327.801q10 12.198 10 27.301v131q0 19.071-12.714 31.786Q809.571-125 790.5-125ZM232-585.5l81-82-23.5-110H183q1.5 41.5 13 88.25t36 103.75Zm364 358q40 19 88.166 31t93.334 14v-107l-102-21.5-79.5 83.5Zm-364-358Zm364 358Z"/></svg>
|
||||
|
After Width: | Height: | Size: 898 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M143.5 891.5q-23.031 0-40.266-17.234Q86 857.031 86 834V318q0-23.031 17.234-40.266Q120.469 260.5 143.5 260.5h516.211q22.289 0 39.789 17.234Q717 294.969 717 318v215.5L849 401q8-7.5 16.75-3.75t8.75 13.063V741q0 10-8.75 13.75t-16.85-4.35L717 618.5V834q0 23.031-17.5 40.266Q682 891.5 659.711 891.5H143.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 416 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M849.5 750.5 717 618.5v114L659.5 675V318H302l-57.5-57.5h415q22.969 0 40.234 17.266Q717 295.031 717 318v215l132.5-132.5q6.5-6.5 15.75-3.167 9.25 3.334 9.25 12.667v331q0 9.625-9.25 13.062Q856 757.5 849.5 750.5Zm-26.5 250-758-758q-8-7.547-8-19.069 0-11.522 9-20.431 8.5-8.5 20-8.5t20.5 8.5l758 758q7.5 7.93 7.5 19.465t-8.5 20.035q-9 9-20.5 9t-20-9Zm-340-502Zm-319.5-238L221 318h-77.5v516h516v-77.5L716 813v21q0 22.969-17.266 40.234Q681.469 891.5 658.5 891.5h-515q-22.969 0-40.234-17.266Q86 856.969 86 834V318q0-22.969 17.266-40.234Q120.531 260.5 143.5 260.5h20Zm236 316.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 686 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M232.5 693q-12.5 0-20.5-8t-8-20.5v-177q0-12.5 8-20.5t20.5-8h129L509 311.5q13.5-13.5 31-6.5t17.5 26v489.5q0 19.5-17.5 26.5t-31-6.5L361.5 693h-129ZM615 742V409.5q55 17 88 63.25T736 576q0 58-33 103.25T615 742ZM500 408.5l-112.5 108h-126v119h126L500 744V408.5ZM379 576Z"/></svg>
|
||||
|
After Width: | Height: | Size: 381 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="44" viewBox="0 96 960 960" width="44"><path fill="white" d="M600.5 904.5q-15.5 5.5-28.25-4T559.5 874q0-7.5 4.25-13.5t11.75-8q89-31.5 143.75-107T774 575q0-94.5-54.5-170.5T575.5 298q-7-2-11.5-8.5t-4.5-14.5q0-16 13.25-25.25t27.75-4.25Q704.5 283 768 373t63.5 202q0 112.5-63.5 202.5t-167.5 127ZM157 693q-12.5 0-20.5-8t-8-20.5v-177q0-12.5 8-20.5t20.5-8h129l147.5-147.5q13.5-13.5 31-6.25T482 331v489.5q0 19-17.5 26.25t-31-6.25L286 693H157Zm382.5 49V409.5q55 17 88 63.25t33 103.25q0 58-33 103.25t-88 62.75Zm-115-333.5L312 516.5H186v119h126L424.5 744V408.5Zm-93 167.5Z"/></svg>
|
||||
|
After Width: | Height: | Size: 616 B |
@@ -0,0 +1,127 @@
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#remote-video-stream {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream {
|
||||
position: absolute;
|
||||
width: 20%;
|
||||
max-width: 20%;
|
||||
object-fit: cover;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
*::-webkit-media-controls {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
*::-webkit-media-controls-panel {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
*::-webkit-media-controls-play-button {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
*::-webkit-media-controls-start-playback-button {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
|
||||
#manage-call {
|
||||
position: absolute;
|
||||
width: fit-content;
|
||||
top: 90%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0);
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-column-gap: 30px;
|
||||
}
|
||||
|
||||
#manage-call button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
#progress {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
margin-left: -52px;
|
||||
margin-top: -52px;
|
||||
border-radius: 50%;
|
||||
border-top: 5px solid white;
|
||||
border-right: 5px solid white;
|
||||
border-bottom: 5px solid white;
|
||||
border-left: 5px solid black;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
-webkit-animation: spin 2s linear infinite;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
|
||||
@-webkit-keyframes spin {
|
||||
0% {
|
||||
-webkit-transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
-webkit-transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#info-block {
|
||||
position: absolute;
|
||||
color: white;
|
||||
line-height: 10px;
|
||||
opacity: 0.8;
|
||||
width: 200px;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
#info-block.audio {
|
||||
text-align: center;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
margin-left: -100px;
|
||||
margin-top: 100px;
|
||||
}
|
||||
|
||||
#info-block.video {
|
||||
left: 16px;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
#audio-call-icon {
|
||||
position: absolute;
|
||||
display: none;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
margin-left: -50px;
|
||||
margin-top: -44px;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use strict";
|
||||
// Override defaults to enable worker on Chrome and Safari
|
||||
useWorker = typeof window.Worker !== "undefined";
|
||||
// Create WebSocket connection.
|
||||
const socket = new WebSocket(`ws://${location.host}`);
|
||||
socket.addEventListener("open", (_event) => {
|
||||
console.log("Opened socket");
|
||||
sendMessageToNative = (msg) => {
|
||||
console.log("Message to server");
|
||||
socket.send(JSON.stringify(msg));
|
||||
};
|
||||
});
|
||||
socket.addEventListener("message", (event) => {
|
||||
const parsed = JSON.parse(event.data);
|
||||
reactOnMessageFromServer(parsed);
|
||||
processCommand(parsed);
|
||||
console.log("Message from server");
|
||||
});
|
||||
socket.addEventListener("close", (_event) => {
|
||||
console.log("Closed socket");
|
||||
sendMessageToNative = (_msg) => {
|
||||
console.log("Tried to send message to native but the socket was closed already");
|
||||
};
|
||||
window.close();
|
||||
});
|
||||
function endCallManually() {
|
||||
sendMessageToNative({ resp: { type: "end" } });
|
||||
}
|
||||
function toggleAudioManually() {
|
||||
if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localMedia) {
|
||||
document.getElementById("toggle-audio").innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Audio)
|
||||
? '<img src="/desktop/images/ic_mic.svg" />'
|
||||
: '<img src="/desktop/images/ic_mic_off.svg" />';
|
||||
}
|
||||
}
|
||||
function toggleSpeakerManually() {
|
||||
if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.remoteStream) {
|
||||
document.getElementById("toggle-speaker").innerHTML = toggleMedia(activeCall.remoteStream, CallMediaType.Audio)
|
||||
? '<img src="/desktop/images/ic_volume_up.svg" />'
|
||||
: '<img src="/desktop/images/ic_volume_down.svg" />';
|
||||
}
|
||||
}
|
||||
function toggleVideoManually() {
|
||||
if (activeCall === null || activeCall === void 0 ? void 0 : activeCall.localMedia) {
|
||||
document.getElementById("toggle-video").innerHTML = toggleMedia(activeCall.localStream, CallMediaType.Video)
|
||||
? '<img src="/desktop/images/ic_videocam_filled.svg" />'
|
||||
: '<img src="/desktop/images/ic_videocam_off.svg" />';
|
||||
}
|
||||
}
|
||||
function reactOnMessageFromServer(msg) {
|
||||
var _a;
|
||||
switch ((_a = msg.command) === null || _a === void 0 ? void 0 : _a.type) {
|
||||
case "capabilities":
|
||||
document.getElementById("info-block").className = msg.command.media;
|
||||
break;
|
||||
case "offer":
|
||||
case "start":
|
||||
document.getElementById("toggle-audio").style.display = "inline-block";
|
||||
document.getElementById("toggle-speaker").style.display = "inline-block";
|
||||
if (msg.command.media == "video") {
|
||||
document.getElementById("toggle-video").style.display = "inline-block";
|
||||
}
|
||||
document.getElementById("info-block").className = msg.command.media;
|
||||
break;
|
||||
case "description":
|
||||
updateCallInfoView(msg.command.state, msg.command.description);
|
||||
if ((activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection.connectionState) == "connected") {
|
||||
document.getElementById("progress").style.display = "none";
|
||||
if (document.getElementById("info-block").className == CallMediaType.Audio) {
|
||||
document.getElementById("audio-call-icon").style.display = "block";
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
function updateCallInfoView(state, description) {
|
||||
document.getElementById("state").innerText = state;
|
||||
document.getElementById("description").innerText = description;
|
||||
}
|
||||
//# sourceMappingURL=ui.js.map
|
||||
@@ -0,0 +1 @@
|
||||
var LZString=function(){function o(o,r){if(!t[o]){t[o]={};for(var n=0;n<o.length;n++)t[o][o.charAt(n)]=n}return t[o][r]}var r=String.fromCharCode,n="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",e="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+-$",t={},i={compressToBase64:function(o){if(null==o)return"";var r=i._compress(o,6,function(o){return n.charAt(o)});switch(r.length%4){default:case 0:return r;case 1:return r+"===";case 2:return r+"==";case 3:return r+"="}},decompressFromBase64:function(r){return null==r?"":""==r?null:i._decompress(r.length,32,function(e){return o(n,r.charAt(e))})},compressToUTF16:function(o){return null==o?"":i._compress(o,15,function(o){return r(o+32)})+" "},decompressFromUTF16:function(o){return null==o?"":""==o?null:i._decompress(o.length,16384,function(r){return o.charCodeAt(r)-32})},compressToUint8Array:function(o){for(var r=i.compress(o),n=new Uint8Array(2*r.length),e=0,t=r.length;t>e;e++){var s=r.charCodeAt(e);n[2*e]=s>>>8,n[2*e+1]=s%256}return n},decompressFromUint8Array:function(o){if(null===o||void 0===o)return i.decompress(o);for(var n=new Array(o.length/2),e=0,t=n.length;t>e;e++)n[e]=256*o[2*e]+o[2*e+1];var s=[];return n.forEach(function(o){s.push(r(o))}),i.decompress(s.join(""))},compressToEncodedURIComponent:function(o){return null==o?"":i._compress(o,6,function(o){return e.charAt(o)})},decompressFromEncodedURIComponent:function(r){return null==r?"":""==r?null:(r=r.replace(/ /g,"+"),i._decompress(r.length,32,function(n){return o(e,r.charAt(n))}))},compress:function(o){return i._compress(o,16,function(o){return r(o)})},_compress:function(o,r,n){if(null==o)return"";var e,t,i,s={},p={},u="",c="",a="",l=2,f=3,h=2,d=[],m=0,v=0;for(i=0;i<o.length;i+=1)if(u=o.charAt(i),Object.prototype.hasOwnProperty.call(s,u)||(s[u]=f++,p[u]=!0),c=a+u,Object.prototype.hasOwnProperty.call(s,c))a=c;else{if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++),s[c]=f++,a=String(u)}if(""!==a){if(Object.prototype.hasOwnProperty.call(p,a)){if(a.charCodeAt(0)<256){for(e=0;h>e;e++)m<<=1,v==r-1?(v=0,d.push(n(m)),m=0):v++;for(t=a.charCodeAt(0),e=0;8>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}else{for(t=1,e=0;h>e;e++)m=m<<1|t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t=0;for(t=a.charCodeAt(0),e=0;16>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1}l--,0==l&&(l=Math.pow(2,h),h++),delete p[a]}else for(t=s[a],e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;l--,0==l&&(l=Math.pow(2,h),h++)}for(t=2,e=0;h>e;e++)m=m<<1|1&t,v==r-1?(v=0,d.push(n(m)),m=0):v++,t>>=1;for(;;){if(m<<=1,v==r-1){d.push(n(m));break}v++}return d.join("")},decompress:function(o){return null==o?"":""==o?null:i._decompress(o.length,32768,function(r){return o.charCodeAt(r)})},_decompress:function(o,n,e){var t,i,s,p,u,c,a,l,f=[],h=4,d=4,m=3,v="",w=[],A={val:e(0),position:n,index:1};for(i=0;3>i;i+=1)f[i]=i;for(p=0,c=Math.pow(2,2),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(t=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;l=r(p);break;case 2:return""}for(f[3]=l,s=l,w.push(l);;){if(A.index>o)return"";for(p=0,c=Math.pow(2,m),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;switch(l=p){case 0:for(p=0,c=Math.pow(2,8),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 1:for(p=0,c=Math.pow(2,16),a=1;a!=c;)u=A.val&A.position,A.position>>=1,0==A.position&&(A.position=n,A.val=e(A.index++)),p|=(u>0?1:0)*a,a<<=1;f[d++]=r(p),l=d-1,h--;break;case 2:return w.join("")}if(0==h&&(h=Math.pow(2,m),m++),f[l])v=f[l];else{if(l!==d)return null;v=s+s.charAt(0)}w.push(v),f[d++]=s+v.charAt(0),h--,s=v,0==h&&(h=Math.pow(2,m),m++)}}};return i}();"function"==typeof define&&define.amd?define(function(){return LZString}):"undefined"!=typeof module&&null!=module&&(module.exports=LZString);
|
||||
@@ -0,0 +1,41 @@
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
#remote-video-stream {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
#local-video-stream {
|
||||
position: absolute;
|
||||
width: 30%;
|
||||
max-width: 30%;
|
||||
object-fit: cover;
|
||||
margin: 16px;
|
||||
border-radius: 16px;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
*::-webkit-media-controls {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
*::-webkit-media-controls-panel {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
*::-webkit-media-controls-play-button {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
*::-webkit-media-controls-start-playback-button {
|
||||
display: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
package chat.simplex.common.platform
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.*
|
||||
import chat.simplex.common.model.*
|
||||
import chat.simplex.common.views.helpers.AlertManager
|
||||
import chat.simplex.common.views.helpers.generalGetString
|
||||
import chat.simplex.common.views.helpers.*
|
||||
import chat.simplex.res.MR
|
||||
import kotlinx.coroutines.*
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||
import uk.co.caprica.vlcj.player.base.State
|
||||
import uk.co.caprica.vlcj.player.component.AudioPlayerComponent
|
||||
import java.io.File
|
||||
import java.util.*
|
||||
import kotlin.math.max
|
||||
|
||||
actual class RecorderNative: RecorderInterface {
|
||||
@@ -38,7 +37,7 @@ actual object AudioPlayer: AudioPlayerInterface {
|
||||
|
||||
// Returns real duration of the track
|
||||
private fun start(fileSource: CryptoFile, seek: Int? = null, onProgressUpdate: (position: Int?, state: TrackState) -> Unit): Int? {
|
||||
val absoluteFilePath = getAppFilePath(fileSource.filePath)
|
||||
val absoluteFilePath = if (fileSource.isAbsolutePath) fileSource.filePath else getAppFilePath(fileSource.filePath)
|
||||
if (!File(absoluteFilePath).exists()) {
|
||||
Log.e(TAG, "No such file: ${fileSource.filePath}")
|
||||
return null
|
||||
@@ -208,6 +207,25 @@ val MediaPlayer.duration: Int
|
||||
get() = media().info().duration().toInt()
|
||||
|
||||
actual object SoundPlayer: SoundPlayerInterface {
|
||||
override fun start(scope: CoroutineScope, sound: Boolean) { /*LALAL*/ }
|
||||
override fun stop() { /*LALAL*/ }
|
||||
var playing = false
|
||||
|
||||
override fun start(scope: CoroutineScope, sound: Boolean) {
|
||||
withBGApi {
|
||||
val tmpFile = File(tmpDir, UUID.randomUUID().toString())
|
||||
tmpFile.deleteOnExit()
|
||||
SoundPlayer::class.java.getResource("/media/ring_once.mp3").openStream()!!.use { it.copyTo(tmpFile.outputStream()) }
|
||||
playing = true
|
||||
while (playing) {
|
||||
if (sound) {
|
||||
AudioPlayer.play(CryptoFile.plain(tmpFile.absolutePath), mutableStateOf(true), mutableStateOf(0), mutableStateOf(0), true)
|
||||
}
|
||||
delay(3500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
playing = false
|
||||
AudioPlayer.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,243 @@
|
||||
package chat.simplex.common.views.call
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
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
|
||||
import org.nanohttpd.protocols.http.response.Response.newFixedLengthResponse
|
||||
import org.nanohttpd.protocols.http.response.Status
|
||||
import org.nanohttpd.protocols.websockets.*
|
||||
import java.io.IOException
|
||||
import java.net.URI
|
||||
|
||||
private const val SERVER_HOST = "localhost"
|
||||
private const val SERVER_PORT = 50395
|
||||
val connections = ArrayList<WebSocket>()
|
||||
|
||||
@Composable
|
||||
actual fun ActiveCallView() {
|
||||
// LALAL
|
||||
val endCall = {
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
BackHandler(onBack = endCall)
|
||||
WebRTCController(chatModel.callCommand) { apiMsg ->
|
||||
Log.d(TAG, "received from WebRTCController: $apiMsg")
|
||||
val call = chatModel.activeCall.value
|
||||
if (call != null) {
|
||||
Log.d(TAG, "has active call $call")
|
||||
when (val r = apiMsg.resp) {
|
||||
is WCallResponse.Capabilities -> withBGApi {
|
||||
val callType = CallType(call.localMedia, r.capabilities)
|
||||
chatModel.controller.apiSendCallInvitation(call.contact, callType)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.InvitationSent, localCapabilities = r.capabilities)
|
||||
}
|
||||
is WCallResponse.Offer -> withBGApi {
|
||||
chatModel.controller.apiSendCallOffer(call.contact, r.offer, r.iceCandidates, call.localMedia, r.capabilities)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.OfferSent, localCapabilities = r.capabilities)
|
||||
}
|
||||
is WCallResponse.Answer -> withBGApi {
|
||||
chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
}
|
||||
is WCallResponse.Ice -> withBGApi {
|
||||
chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
|
||||
}
|
||||
is WCallResponse.Connection ->
|
||||
try {
|
||||
val callStatus = json.decodeFromString<WebRTCCallStatus>("\"${r.state.connectionState}\"")
|
||||
if (callStatus == WebRTCCallStatus.Connected) {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectedAt = Clock.System.now())
|
||||
}
|
||||
withBGApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
|
||||
} catch (e: Error) {
|
||||
Log.d(TAG, "call status ${r.state.connectionState} not used")
|
||||
}
|
||||
is WCallResponse.Connected -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Connected, connectionInfo = r.connectionInfo)
|
||||
}
|
||||
is WCallResponse.End -> {
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
}
|
||||
is WCallResponse.Ended -> {
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Ended)
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
chatModel.showCallView.value = false
|
||||
}
|
||||
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
|
||||
is WCallCommand.Answer ->
|
||||
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
|
||||
is WCallCommand.Media -> {
|
||||
when (cmd.media) {
|
||||
CallMediaType.Video -> chatModel.activeCall.value = call.copy(videoEnabled = cmd.enable)
|
||||
CallMediaType.Audio -> chatModel.activeCall.value = call.copy(audioEnabled = cmd.enable)
|
||||
}
|
||||
}
|
||||
is WCallCommand.Camera -> {
|
||||
chatModel.activeCall.value = call.copy(localCamera = cmd.camera)
|
||||
if (!call.audioEnabled) {
|
||||
chatModel.callCommand.add(WCallCommand.Media(CallMediaType.Audio, enable = false))
|
||||
}
|
||||
}
|
||||
is WCallCommand.End ->
|
||||
chatModel.showCallView.value = false
|
||||
else -> {}
|
||||
}
|
||||
is WCallResponse.Error -> {
|
||||
Log.e(TAG, "ActiveCallView: command error ${r.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SendStateUpdates()
|
||||
DisposableEffect(Unit) {
|
||||
chatModel.activeCallViewIsVisible.value = true
|
||||
// After the first call, End command gets added to the list which prevents making another calls
|
||||
chatModel.callCommand.removeAll { it is WCallCommand.End }
|
||||
onDispose {
|
||||
chatModel.activeCallViewIsVisible.value = false
|
||||
chatModel.callCommand.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendStateUpdates() {
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { chatModel.activeCall.value }
|
||||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.collect { call ->
|
||||
val state = call.callState.text
|
||||
val connInfo = call.connectionInfo
|
||||
// val connInfoText = if (connInfo == null) "" else " (${connInfo.text}, ${connInfo.protocolText})"
|
||||
val connInfoText = if (connInfo == null) "" else " (${connInfo.text})"
|
||||
val description = call.encryptionStatus + connInfoText
|
||||
chatModel.callCommand.add(WCallCommand.Description(state, description))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val server = remember {
|
||||
uriHandler.openUri("http://${SERVER_HOST}:$SERVER_PORT/simplex/call/")
|
||||
startServer(onResponse)
|
||||
}
|
||||
fun processCommand(cmd: WCallCommand) {
|
||||
val apiCall = WVAPICall(command = cmd)
|
||||
for (connection in connections.toList()) {
|
||||
try {
|
||||
connection.send(json.encodeToString(apiCall))
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send message to browser: ${e.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
processCommand(WCallCommand.End)
|
||||
server.stop()
|
||||
connections.clear()
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { callCommand.firstOrNull() }
|
||||
.distinctUntilChanged()
|
||||
.filterNotNull()
|
||||
.collect {
|
||||
while (connections.isEmpty()) {
|
||||
delay(100)
|
||||
}
|
||||
while (callCommand.isNotEmpty()) {
|
||||
val cmd = callCommand.removeFirst()
|
||||
Log.d(TAG, "WebRTCController LaunchedEffect executing $cmd")
|
||||
processCommand(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startServer(onResponse: (WVAPIMessage) -> Unit): NanoWSD {
|
||||
val server = object: NanoWSD(SERVER_HOST, SERVER_PORT) {
|
||||
override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session)
|
||||
|
||||
@Suppress("NewApi")
|
||||
fun resourcesToResponse(path: String): Response {
|
||||
val uri = Class.forName("chat.simplex.common.AppKt").getResource("/assets/www$path") ?: return resourceNotFound
|
||||
val response = newFixedLengthResponse(
|
||||
Status.OK, getMimeTypeForFile(uri.file),
|
||||
uri.openStream().readAllBytes()
|
||||
)
|
||||
response.setKeepAlive(true)
|
||||
response.setUseGzip(true)
|
||||
return response
|
||||
}
|
||||
|
||||
val resourceNotFound = newFixedLengthResponse(Status.NOT_FOUND, "text/plain", "This page couldn't be found")
|
||||
|
||||
override fun handle(session: IHTTPSession): Response {
|
||||
return when {
|
||||
session.headers["upgrade"] == "websocket" -> super.handle(session)
|
||||
session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html")
|
||||
else -> resourcesToResponse(URI.create(session.uri).path)
|
||||
}
|
||||
}
|
||||
}
|
||||
server.start(60_000_000)
|
||||
return server
|
||||
}
|
||||
|
||||
class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTTPSession) : WebSocket(handshakeRequest) {
|
||||
override fun onOpen() {
|
||||
connections.add(this)
|
||||
}
|
||||
|
||||
override fun onClose(closeCode: CloseCode?, reason: String?, initiatedByRemote: Boolean) {
|
||||
onResponse(WVAPIMessage(null, WCallResponse.End))
|
||||
}
|
||||
|
||||
override fun onMessage(message: WebSocketFrame) {
|
||||
Log.d(TAG, "MyWebSocket.onMessage")
|
||||
try {
|
||||
// for debugging
|
||||
// onResponse(message.textPayload)
|
||||
onResponse(json.decodeFromString(message.textPayload))
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "failed parsing browser message: $message")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPong(pong: WebSocketFrame?) = Unit
|
||||
|
||||
override fun onException(exception: IOException) {
|
||||
Log.e(TAG, "WebSocket exception: ${exception.stackTraceToString()}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import androidx.compose.ui.graphics.drawscope.ContentDrawScope
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.views.helpers.*
|
||||
|
||||
private object NoIndication : Indication {
|
||||
object NoIndication : Indication {
|
||||
private object NoIndicationInstance : IndicationInstance {
|
||||
override fun ContentDrawScope.drawIndication() {
|
||||
drawContent()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
import androidx.compose.foundation.*
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.MaterialTheme
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chat.simplex.common.platform.*
|
||||
import chat.simplex.common.ui.theme.*
|
||||
import chat.simplex.common.views.call.CallMediaType
|
||||
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.flow.MutableStateFlow
|
||||
|
||||
@Composable
|
||||
actual fun DesktopActiveCallOverlayLayout(newChatSheetState: MutableStateFlow<AnimatedViewState>) {
|
||||
val call = remember { chatModel.activeCall}.value
|
||||
// if (call?.callState == CallState.Connected && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||
if (call != null && !newChatSheetState.collectAsState().value.isVisible()) {
|
||||
val showMenu = remember { mutableStateOf(false) }
|
||||
val media = call.peerMedia ?: call.localMedia
|
||||
CompositionLocalProvider(
|
||||
LocalIndication provides NoIndication
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(end = 71.dp, bottom = 92.dp)
|
||||
.size(67.dp)
|
||||
.combinedClickable(onClick = {
|
||||
val chat = chatModel.getChat(call.contact.id)
|
||||
if (chat != null) {
|
||||
withApi {
|
||||
openChat(chat.chatInfo, chatModel)
|
||||
}
|
||||
}
|
||||
},
|
||||
onLongClick = { showMenu.value = true })
|
||||
.onRightClick { showMenu.value = true },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(Modifier.background(MaterialTheme.colors.background, CircleShape)) {
|
||||
ProfileImageForActiveCall(size = 56.dp, image = call.contact.profile.image)
|
||||
}
|
||||
Box(Modifier.padding().background(SimplexGreen, CircleShape).padding(4.dp).align(Alignment.TopEnd)) {
|
||||
if (media == CallMediaType.Video) {
|
||||
Icon(painterResource(MR.images.ic_videocam_filled), stringResource(MR.strings.icon_descr_video_call), Modifier.size(18.dp), tint = Color.White)
|
||||
} else {
|
||||
Icon(painterResource(MR.images.ic_call_filled), stringResource(MR.strings.icon_descr_audio_call), Modifier.size(18.dp), tint = Color.White)
|
||||
}
|
||||
}
|
||||
DefaultDropdownMenu(showMenu) {
|
||||
ItemAction(stringResource(MR.strings.icon_descr_hang_up), painterResource(MR.images.ic_call_end_filled), color = MaterialTheme.colors.error, onClick = {
|
||||
withBGApi { chatModel.callManager.endCall(call) }
|
||||
showMenu.value = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||