mobile: webrtc calls (#661)

* mobile: webrtc calls work on Android and iOS separately

* core: only send public key with offer if invitation/current call supports e2ee

* update npm module
This commit is contained in:
Evgeny Poberezkin
2022-05-18 07:01:32 +01:00
committed by GitHub
parent 106dceabfc
commit dc418923ac
20 changed files with 361 additions and 155 deletions
-1
View File
@@ -13,7 +13,6 @@
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
</GradleProjectSettings>
</option>
</component>
+3 -8
View File
@@ -16,15 +16,14 @@ var TransformOperation;
TransformOperation["Encrypt"] = "encrypt";
TransformOperation["Decrypt"] = "decrypt";
})(TransformOperation || (TransformOperation = {}));
;
(function () {
const processCommand = (function () {
let activeCall;
function defaultCallConfig(encodedInsertableStreams) {
return {
peerConnectionConfig: {
iceServers: [
{ urls: "stun:stun.simplex.chat:5349" },
// {urls: "turn:turn.simplex.chat:5349", username: "private", credential: "yleob6AVkiNI87hpR94Z"},
{ urls: "turn:turn.simplex.chat:5349", username: "private", credential: "yleob6AVkiNI87hpR94Z" },
],
iceCandidatePoolSize: 10,
encodedInsertableStreams,
@@ -119,7 +118,6 @@ var TransformOperation;
function parse(s) {
return JSON.parse(LZString.decompressFromBase64(s));
}
Object.defineProperty(window, "processCommand", { value: processCommand });
async function processCommand(body) {
const { corrId, command } = body;
const pc = activeCall === null || activeCall === void 0 ? void 0 : activeCall.connection;
@@ -135,9 +133,6 @@ var TransformOperation;
if (activeCall) {
resp = { type: "error", message: "start: call already started" };
}
else if (!supportsInsertableStreams(command.useWorker) && command.aesKey) {
resp = { type: "error", message: "start: encryption is not supported" };
}
else {
const { media, useWorker } = command;
const encryption = supportsInsertableStreams(useWorker);
@@ -386,6 +381,7 @@ var TransformOperation;
for (const t of tracks)
t.enabled = enable;
}
return processCommand;
})();
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
function callCryptoFunction() {
@@ -432,7 +428,6 @@ function callCryptoFunction() {
}
function decodeAesKey(aesKey) {
const keyData = callCrypto.decodeBase64url(callCrypto.encodeAscii(aesKey));
console.log("keyData", keyData);
return crypto.subtle.importKey("raw", keyData, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
}
function concatN(...bs) {
@@ -21,6 +21,7 @@ import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.NtfManager
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.SplashView
import chat.simplex.app.views.call.ActiveCallView
import chat.simplex.app.views.chat.ChatView
import chat.simplex.app.views.chatlist.ChatListView
import chat.simplex.app.views.chatlist.openChat
@@ -90,7 +91,8 @@ fun MainPage(chatModel: ChatModel) {
when {
onboarding == null || userCreated == null -> SplashView()
onboarding == OnboardingStage.OnboardingComplete && userCreated ->
if (chatModel.chatId.value == null) ChatListView(chatModel)
if (chatModel.showCallView.value) ActiveCallView(chatModel)
else if (chatModel.chatId.value == null) ChatListView(chatModel)
else ChatView(chatModel)
onboarding == OnboardingStage.Step1_SimpleXInfo ->
Box(Modifier.padding(horizontal = 20.dp)) {
@@ -45,6 +45,7 @@ class ChatModel(val controller: ChatController) {
val activeCallInvitation = mutableStateOf<ContactRef?>(null)
val activeCall = mutableStateOf<Call?>(null)
val callCommand = mutableStateOf<WCallCommand?>(null)
val showCallView = mutableStateOf(false)
fun updateUserProfile(profile: Profile) {
val user = currentUser.value
@@ -7,6 +7,8 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import chat.simplex.app.*
import chat.simplex.app.views.call.CallInvitation
import chat.simplex.app.views.call.CallMediaType
import kotlinx.datetime.Clock
class NtfManager(val context: Context) {
@@ -56,7 +58,7 @@ class NtfManager(val context: Context) {
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setAutoCancel(true)
.setContentIntent(getMsgPendingIntent(cInfo))
.setContentIntent(getMsgPendingIntent(cInfo.id))
.setSilent(recentNotification)
.build()
@@ -76,6 +78,37 @@ class NtfManager(val context: Context) {
}
}
fun notifyCallInvitation(contact: Contact, invitation: CallInvitation) {
Log.d(TAG, "notifyCallInvitationReceived ${contact.id}")
val notification = NotificationCompat.Builder(context, MessageChannel)
.setContentTitle(contact.displayName)
.setContentText("Incoming ${invitation.peerMedia} call (${if (invitation.sharedKey == null) "not e2e encrypted" else "e2e encrypted"})")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setGroup(MessageGroup)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setSmallIcon(R.drawable.ntf_icon)
.setColor(0x88FFFF)
.setAutoCancel(true)
.setContentIntent(getMsgPendingIntent(contact.id))
.setSilent(false)
.build()
// val summary = NotificationCompat.Builder(context, MessageChannel)
// .setSmallIcon(R.drawable.ntf_icon)
// .setColor(0x88FFFF)
// .setGroup(MessageGroup)
// .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
// .setGroupSummary(true)
// .setContentIntent(getSummaryNtfIntent())
// .build()
with(NotificationManagerCompat.from(context)) {
notify(0, notification)
// notify(0, summary)
}
}
private fun hideSecrets(cItem: ChatItem) : String {
val md = cItem.formattedText
return if (md == null) {
@@ -93,12 +126,12 @@ class NtfManager(val context: Context) {
}
}
private fun getMsgPendingIntent(cInfo: ChatInfo) : PendingIntent{
Log.d(TAG, "getMsgPendingIntent ${cInfo.id}")
private fun getMsgPendingIntent(chatId: String) : PendingIntent{
Log.d(TAG, "getMsgPendingIntent $chatId")
val uniqueInt = (System.currentTimeMillis() and 0xfffffff).toInt()
val intent = Intent(context, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
.putExtra("chatId", cInfo.id)
.putExtra("chatId", chatId)
.setAction(OpenChatAction)
return TaskStackBuilder.create(context).run {
addNextIntentWithParentStack(intent)
@@ -369,15 +369,9 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
return r is CR.CmdOk
}
suspend fun apiCallStatus(contact: Contact, status: String): Boolean {
try {
val callStatus = WebRTCCallStatus.valueOf(status)
val r = sendCmd(CC.ApiCallStatus(contact, callStatus))
return r is CR.CmdOk
} catch (e: Error) {
Log.d(TAG,"apiCallStatus: call status $status not used")
return false
}
suspend fun apiCallStatus(contact: Contact, status: WebRTCCallStatus): Boolean {
val r = sendCmd(CC.ApiCallStatus(contact, status))
return r is CR.CmdOk
}
suspend fun apiChatRead(type: ChatType, id: Long, range: CC.ItemRange): Boolean {
@@ -504,11 +498,83 @@ open class ChatController(private val ctrl: ChatCtrl, private val ntfManager: Nt
removeFile(appContext, fileName)
}
}
is CR.CallInvitation -> {
val invitation = CallInvitation(r.callType.media, r.sharedKey)
chatModel.callInvitations[r.contact.id] = invitation
if (chatModel.activeCallInvitation.value == null) {
chatModel.activeCallInvitation.value = ContactRef(r.contact.apiId, r.contact.localDisplayName)
}
ntfManager.notifyCallInvitation(r.contact, invitation)
val encryptionText = if (r.sharedKey == null) "without e2e encryption" else "with e2e encryption"
AlertManager.shared.showAlertDialog(
title = "Incoming call",
text = "${r.contact.displayName} wants to start ${r.callType.media} call with you (${encryptionText})",
confirmText = "Answer", // generalGetString(R.string.answer),
onConfirm = {
Log.e(TAG, "showAlertDialog onConfirm ${chatModel.activeCallInvitation.value}")
if (chatModel.activeCallInvitation.value == null) {
AlertManager.shared.hideAlert()
AlertManager.shared.showAlertMsg("Call already ended!")
} else {
Log.e(TAG, "showAlertDialog onConfirm has activeCallInvitation ${chatModel.activeCallInvitation.value}")
chatModel.activeCallInvitation.value = null
chatModel.activeCall.value = Call(
contact = r.contact,
callState = CallState.InvitationReceived,
localMedia = invitation.peerMedia
)
chatModel.callCommand.value = WCallCommand.Start(invitation.peerMedia, invitation.sharedKey)
Log.e(TAG, "showAlertDialog onConfirm ${chatModel.callCommand.value}")
chatModel.showCallView.value = true
}
},
onDismiss = {
chatModel.activeCallInvitation == null
}
)
}
is CR.CallOffer -> {
// TODO askConfirmation?
// TODO check encryption is compatible
withCall(r, r.contact) { call ->
chatModel.activeCall.value = call.copy(callState = CallState.OfferReceived, peerMedia = r.callType.media, sharedKey = r.sharedKey)
chatModel.callCommand.value = WCallCommand.Offer(offer = r.offer.rtcSession, iceCandidates = r.offer.rtcIceCandidates, media = r.callType.media, aesKey = r.sharedKey)
}
}
is CR.CallAnswer -> {
withCall(r, r.contact) { call ->
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
chatModel.callCommand.value = WCallCommand.Answer(answer = r.answer.rtcSession, iceCandidates = r.answer.rtcIceCandidates)
}
}
is CR.CallExtraInfo -> {
withCall(r, r.contact) { call ->
chatModel.callCommand.value = WCallCommand.Ice(iceCandidates = r.extraInfo.rtcIceCandidates)
}
}
is CR.CallEnded -> {
withCall(r, r.contact) { call ->
chatModel.callCommand.value = WCallCommand.End()
chatModel.activeCall.value = null
chatModel.activeCallInvitation.value = null
chatModel.callCommand.value = null
chatModel.showCallView.value = false
}
}
else ->
Log.d(TAG , "unsupported event: ${r.responseType}")
}
}
private fun withCall(r: CR, contact: Contact, perform: (Call) -> Unit) {
val call = chatModel.activeCall?.value
if (call != null && call.contact.apiId == contact.apiId) {
perform(call)
} else {
Log.d(TAG, "processReceivedMsg: ignoring ${r.responseType}, not in call with the contact ${contact.id}")
}
}
private fun chatItemSimpleUpdate(aChatItem: AChatItem) {
val cInfo = aChatItem.chatInfo
val cItem = aChatItem.chatItem
@@ -898,8 +964,8 @@ sealed class CR {
@Serializable @SerialName("sndFileCancelled") class SndFileCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndFileRcvCancelled") class SndFileRcvCancelled(val chatItem: AChatItem, val sndFileTransfer: SndFileTransfer): CR()
@Serializable @SerialName("sndGroupFileCancelled") class SndGroupFileCancelled(val chatItem: AChatItem, val fileTransferMeta: FileTransferMeta, val sndFileTransfers: List<SndFileTransfer>): CR()
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String?): CR()
@Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String?, val askConfirmation: Boolean): CR()
@Serializable @SerialName("callInvitation") class CallInvitation(val contact: Contact, val callType: CallType, val sharedKey: String? = null): CR()
@Serializable @SerialName("callOffer") class CallOffer(val contact: Contact, val callType: CallType, val offer: WebRTCSession, val sharedKey: String? = null, val askConfirmation: Boolean): CR()
@Serializable @SerialName("callAnswer") class CallAnswer(val contact: Contact, val answer: WebRTCSession): CR()
@Serializable @SerialName("callExtraInfo") class CallExtraInfo(val contact: Contact, val extraInfo: WebRTCExtraInfo): CR()
@Serializable @SerialName("callEnded") class CallEnded(val contact: Contact): CR()
@@ -23,67 +23,139 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.webkit.WebViewAssetLoader
import androidx.webkit.WebViewClientCompat
import chat.simplex.app.TAG
import chat.simplex.app.model.ChatModel
import chat.simplex.app.model.json
import chat.simplex.app.views.helpers.TextEditor
import chat.simplex.app.views.helpers.withApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import kotlinx.coroutines.delay
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
@Composable
fun VideoCallView(close: () -> Unit) {
val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") }
val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java)
BackHandler(onBack = close)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier
.background(MaterialTheme.colors.background)
.fillMaxSize()
) {
WebRTCView(callCommand) { resp ->
// for debugging
// commandText.value = resp
commandText.value = json.encodeToString(resp)
}
TextEditor(Modifier.height(180.dp), text = commandText)
Row(
Modifier
.fillMaxWidth()
.padding(bottom = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Button(onClick = {
val clip: ClipData = ClipData.newPlainText("js command", commandText.value)
clipboard?.setPrimaryClip(clip)
}) { Text("Copy") }
Button(onClick = {
try {
val apiCall: WVAPICall = json.decodeFromString(commandText.value)
commandText.value = ""
println("sending: ${commandText.value}")
callCommand.value = apiCall.command
} catch(e: Error) {
println("error parsing command: ${commandText.value}")
println(e)
fun ActiveCallView(chatModel: ChatModel) {
val endCall = {
Log.e(TAG, "ActiveCallView: endCall")
chatModel.activeCall.value = null
chatModel.activeCallInvitation.value = null
chatModel.callCommand.value = null
chatModel.showCallView.value = false
}
BackHandler(onBack = endCall)
Box(Modifier.fillMaxSize()) {
WebRTCView(chatModel.callCommand) { apiMsg ->
Log.e(TAG, "received from WebRTCView: $apiMsg")
val call = chatModel.activeCall.value
if (call != null) {
Log.e(TAG, "has active call $call")
when (val r = apiMsg.resp) {
is WCallResponse.Capabilities -> withApi {
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 {
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 {
chatModel.controller.apiSendCallAnswer(call.contact, r.answer, r.iceCandidates)
chatModel.activeCall.value = call.copy(callState = CallState.Negotiated)
}
is WCallResponse.Ice -> withApi {
chatModel.controller.apiSendCallExtraInfo(call.contact, r.iceCandidates)
}
is WCallResponse.Connection ->
try {
val callStatus = WebRTCCallStatus.valueOf(r.state.connectionState)
if (callStatus == WebRTCCallStatus.Connected) {
chatModel.activeCall.value = call.copy(callState = CallState.Connected)
}
withApi { chatModel.controller.apiCallStatus(call.contact, callStatus) }
} catch (e: Error) {
Log.d(TAG,"call status ${r.state.connectionState} not used")
}
is WCallResponse.Ended -> endCall()
is WCallResponse.Ok -> when (val cmd = apiMsg.command) {
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.End -> endCall()
else -> {}
}
is WCallResponse.Error -> {
Log.e(TAG, "ActiveCallView: command error ${r.message}")
}
}
}) { Text("Send") }
Button(onClick = {
commandText.value = ""
}) { Text("Clear") }
}
}
ActiveCallOverlay()
}
}
@Composable
fun ActiveCallOverlay() {
}
//@Composable
//fun CallViewDebug(close: () -> Unit) {
// val callCommand = remember { mutableStateOf<WCallCommand?>(null)}
// val commandText = remember { mutableStateOf("{\"command\": {\"type\": \"start\", \"media\": \"video\", \"aesKey\": \"FwW+t6UbnwHoapYOfN4mUBUuqR7UtvYWxW16iBqM29U=\"}}") }
// val clipboard = ContextCompat.getSystemService(LocalContext.current, ClipboardManager::class.java)
//
// BackHandler(onBack = close)
// Column(
// horizontalAlignment = Alignment.CenterHorizontally,
// verticalArrangement = Arrangement.spacedBy(12.dp),
// modifier = Modifier
// .background(MaterialTheme.colors.background)
// .fillMaxSize()
// ) {
// WebRTCView(callCommand) { apiMsg ->
// // for debugging
// // commandText.value = apiMsg
// commandText.value = json.encodeToString(apiMsg)
// }
//
// TextEditor(Modifier.height(180.dp), text = commandText)
//
// Row(
// Modifier
// .fillMaxWidth()
// .padding(bottom = 6.dp),
// horizontalArrangement = Arrangement.SpaceBetween
// ) {
// Button(onClick = {
// val clip: ClipData = ClipData.newPlainText("js command", commandText.value)
// clipboard?.setPrimaryClip(clip)
// }) { Text("Copy") }
// Button(onClick = {
// try {
// val apiCall: WVAPICall = json.decodeFromString(commandText.value)
// commandText.value = ""
// println("sending: ${commandText.value}")
// callCommand.value = apiCall.command
// } catch(e: Error) {
// println("error parsing command: ${commandText.value}")
// println(e)
// }
// }) { Text("Send") }
// Button(onClick = {
// commandText.value = ""
// }) { Text("Clear") }
// }
// }
//}
@Composable
// for debugging
// fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (String) -> Unit) {
fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessage) -> Unit) {
lateinit var wv: WebView
val webView = remember { mutableStateOf<WebView?>(null) }
val permissionsState = rememberMultiplePermissionsState(
permissions = listOf(
Manifest.permission.CAMERA,
@@ -92,7 +164,7 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
Manifest.permission.INTERNET
)
)
fun processCommand(cmd: WCallCommand) {
fun processCommand(wv: WebView, cmd: WCallCommand) {
val apiCall = WVAPICall(command = cmd)
wv.evaluateJavascript("processCommand(${json.encodeToString(apiCall)})", null)
}
@@ -104,30 +176,27 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
processCommand(WCallCommand.End())
val wv = webView.value
if (wv != null) processCommand(wv, WCallCommand.End())
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
LaunchedEffect(callCommand.value) {
LaunchedEffect(callCommand.value, webView.value) {
val cmd = callCommand.value
if (cmd != null) {
val wv = webView.value
if (cmd != null && wv != null) {
Log.d(TAG, "WebRTCView LaunchedEffect executing $cmd")
processCommand(wv, cmd)
callCommand.value = null
processCommand(cmd)
}
}
val assetLoader = WebViewAssetLoader.Builder()
.addPathHandler("/assets/www/", WebViewAssetLoader.AssetsPathHandler(LocalContext.current))
.build()
if (permissionsState.allPermissionsGranted) {
Box(
Modifier
.fillMaxWidth()
.aspectRatio(ratio = 1F)
) {
Box(Modifier.fillMaxSize()) {
AndroidView(
factory = { AndroidViewContext ->
WebView(AndroidViewContext).apply {
@@ -154,16 +223,19 @@ fun WebRTCView(callCommand: MutableState<WCallCommand?>, onResponse: (WVAPIMessa
webViewSettings.allowContentAccess = true
webViewSettings.javaScriptEnabled = true
webViewSettings.mediaPlaybackRequiresUserGesture = false
webViewSettings.allowFileAccessFromFileURLs = true;
webViewSettings.cacheMode = WebSettings.LOAD_NO_CACHE
this.loadUrl("file:android_asset/www/call.html")
}
}
) {
wv = it
) { wv ->
Log.e(TAG, "WebRTCView: webview ready")
// for debugging
// wv.evaluateJavascript("sendMessageToNative = ({resp}) => WebRTCInterface.postMessage(JSON.stringify({command: resp}))", null)
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
withApi {
delay(2000L)
wv.evaluateJavascript("sendMessageToNative = (msg) => WebRTCInterface.postMessage(JSON.stringify(msg))", null)
webView.value = wv
}
}
}
} else {
@@ -6,7 +6,7 @@ import chat.simplex.app.views.helpers.generalGetString
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class Call(
data class Call(
val contact: Contact,
val callState: CallState,
val localMedia: CallMediaType,
@@ -46,7 +46,7 @@ enum class CallState {
sealed class WCallCommand {
@Serializable @SerialName("capabilities") class Capabilities(): WCallCommand()
@Serializable @SerialName("start") class Start(val media: CallMediaType, val aesKey: String? = null): WCallCommand()
@Serializable @SerialName("offer") class Accept(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null): WCallCommand()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null): WCallCommand()
@Serializable @SerialName("answer") class Answer (val answer: String, val iceCandidates: String): WCallCommand()
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallCommand()
@Serializable @SerialName("media") class Media(val media: CallMediaType, val enable: Boolean): WCallCommand()
@@ -56,23 +56,20 @@ sealed class WCallCommand {
@Serializable
sealed class WCallResponse {
@Serializable @SerialName("capabilities") class Capabilities(val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String): WCallResponse()
// TODO remove accept, it is needed for debugging
@Serializable @SerialName("accept") class Accept(val offer: String, val iceCandidates: String, val media: CallMediaType, val aesKey: String? = null): WCallResponse()
@Serializable @SerialName("offer") class Offer(val offer: String, val iceCandidates: String, val capabilities: CallCapabilities): WCallResponse()
@Serializable @SerialName("answer") class Answer(val answer: String, val iceCandidates: String): WCallResponse()
@Serializable @SerialName("ice") class Ice(val iceCandidates: String): WCallResponse()
@Serializable @SerialName("connection") class Connection(val state: ConnectionState): WCallResponse()
@Serializable @SerialName("ended") class Ended(): WCallResponse()
@Serializable @SerialName("ok") class Ok(): WCallResponse()
@Serializable @SerialName("error") class Error(val message: String): WCallResponse()
@Serializable class Invalid(val str: String): WCallResponse()
}
@Serializable class WebRTCCallOffer(val callType: CallType, val rtcSession: WebRTCSession)
@Serializable class WebRTCSession(val rtcSession: String, val rtcIceCandidates: String)
@Serializable class WebRTCExtraInfo(val rtcIceCandidates: String)
@Serializable class CallType(val media: CallMediaType, val capabilities: CallCapabilities)
@Serializable class CallInvitation(val peerMedia: CallMediaType?, val sharedKey: String?)
@Serializable class CallInvitation(val peerMedia: CallMediaType, val sharedKey: String?)
@Serializable class CallCapabilities(val encryption: Boolean)
@Serializable
@@ -12,7 +12,7 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.ArrowBackIos
import androidx.compose.material.icons.outlined.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.mapSaver
import androidx.compose.runtime.saveable.rememberSaveable
@@ -32,6 +32,7 @@ import chat.simplex.app.SimplexApp.Companion.context
import chat.simplex.app.TAG
import chat.simplex.app.model.*
import chat.simplex.app.ui.theme.*
import chat.simplex.app.views.call.*
import chat.simplex.app.views.chat.item.ChatItemView
import chat.simplex.app.views.chatlist.openChat
import chat.simplex.app.views.helpers.*
@@ -117,6 +118,14 @@ fun ChatView(chatModel: ChatModel) {
chatModel.upsertChatItem(cInfo, cItem)
}
}
},
startCall = { media ->
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()
}
}
)
}
@@ -137,7 +146,8 @@ fun ChatLayout(
info: () -> Unit,
openDirectChat: (Long) -> Unit,
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long) -> Unit
receiveFile: (Long) -> Unit,
startCall: (CallMediaType) -> Unit
) {
fun onImageChange(bitmap: Bitmap) {
val imagePreview = resizeImageToStrSize(bitmap, maxDataSize = 14000)
@@ -173,7 +183,7 @@ fun ChatLayout(
sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp)
) {
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info) },
topBar = { ChatInfoToolbar(chat, back, info, startCall) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
@@ -187,7 +197,7 @@ fun ChatLayout(
}
@Composable
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit, startCall: (CallMediaType) -> Unit) {
Column {
Box(
Modifier
@@ -205,6 +215,16 @@ fun ChatInfoToolbar(chat: Chat, back: () -> Unit, info: () -> Unit) {
modifier = Modifier.padding(10.dp)
)
}
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
IconButton(onClick = { startCall(CallMediaType.Video) }) {
Icon(
Icons.Outlined.Videocam,
"video call",
tint = MaterialTheme.colors.primary,
modifier = Modifier.padding(10.dp)
)
}
}
Row(
Modifier
.padding(horizontal = 68.dp)
@@ -378,7 +398,8 @@ fun PreviewChatLayout() {
info = {},
openDirectChat = {},
deleteMessage = { _, _ -> },
receiveFile = {}
receiveFile = {},
startCall = {}
)
}
}
@@ -423,7 +444,8 @@ fun PreviewGroupChatLayout() {
info = {},
openDirectChat = {},
deleteMessage = { _, _ -> },
receiveFile = {}
receiveFile = {},
startCall = {}
)
}
}
@@ -24,7 +24,6 @@ import chat.simplex.app.model.Profile
import chat.simplex.app.ui.theme.HighOrLowlight
import chat.simplex.app.ui.theme.SimpleXTheme
import chat.simplex.app.views.TerminalView
import chat.simplex.app.views.call.VideoCallView
import chat.simplex.app.views.helpers.*
import chat.simplex.app.views.onboarding.SimpleXInfo
@@ -45,8 +44,8 @@ fun SettingsView(chatModel: ChatModel) {
},
showModal = { modalView -> { ModalManager.shared.showModal { modalView(chatModel) } } },
showCustomModal = { modalView -> { ModalManager.shared.showCustomModal { close -> modalView(chatModel, close) } } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } },
showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> VideoCallView(close) } },
showTerminal = { ModalManager.shared.showCustomModal { close -> TerminalView(chatModel, close) } }
// showVideoChatPrototype = { ModalManager.shared.showCustomModal { close -> CallViewDebug(close) } },
)
}
}
@@ -62,7 +61,7 @@ fun SettingsLayout(
showModal: (@Composable (ChatModel) -> Unit) -> (() -> Unit),
showCustomModal: (@Composable (ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showTerminal: () -> Unit,
showVideoChatPrototype: () -> Unit
// showVideoChatPrototype: () -> Unit
) {
val uriHandler = LocalUriHandler.current
Surface(
@@ -208,8 +207,8 @@ fun SettingsLayout(
Text(annotatedStringResource(R.string.install_simplex_chat_for_terminal))
}
Divider(Modifier.padding(horizontal = 8.dp))
SettingsSectionView(showVideoChatPrototype) {
// SettingsSectionView() {
// SettingsSectionView(showVideoChatPrototype) {
SettingsSectionView() {
Text("v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})")
}
}
@@ -246,7 +245,7 @@ fun PreviewSettingsLayout() {
showModal = {{}},
showCustomModal = {{}},
showTerminal = {},
showVideoChatPrototype = {}
// showVideoChatPrototype = {}
)
}
}