android, desktop: checking for camera in calls and handle uninitialized call (#4997)

* android, desktop: checking for camera in calls and handle uninitialized call

* explanation for situation without permission

* reorder

* reorder

* strings

* font

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
Stanislav Dmitrenko 2024-10-08 23:16:34 +07:00 committed by GitHub
parent 35fc0544a0
commit 54b40a5838
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 201 additions and 11 deletions

View file

@ -83,6 +83,7 @@ enum class CallState {
@Serializable
sealed class WCallCommand {
@Serializable @SerialName("capabilities") data class Capabilities(val media: CallMediaType): WCallCommand()
@Serializable @SerialName("permission") data class Permission(val title: String, val chrome: String, val safari: String): 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()

View file

@ -1047,6 +1047,9 @@
<string name="call_already_ended">Call already ended!</string>
<string name="icon_descr_video_call">video call</string>
<string name="icon_descr_audio_call">audio call</string>
<string name="call_desktop_permission_denied_title">To make calls, allow to use your microphone. End the call and try to call again.</string>
<string name="call_desktop_permission_denied_chrome">Click info button near address field to allow using microphone.</string>
<string name="call_desktop_permission_denied_safari">Open Safari Settings / Websites / Microphone, then choose Allow for localhost.</string>
<!-- Call settings -->
<string name="settings_audio_video_calls">Audio &amp; video calls</string>

View file

@ -31,6 +31,7 @@ var sendMessageToNative = (msg) => console.log(JSON.stringify(msg));
var toggleScreenShare = async () => { };
var localOrPeerMediaSourcesChanged = (_call) => { };
var inactiveCallMediaSourcesChanged = (_inactiveCallMediaSources) => { };
var failedToGetPermissions = (_title, _description) => { };
// Global object with cryptrographic/encoding functions
const callCrypto = callCryptoFunction();
var TransformOperation;
@ -159,7 +160,7 @@ const processCommand = (function () {
try {
localStream = (notConnectedCall === null || notConnectedCall === void 0 ? void 0 : notConnectedCall.localStream)
? notConnectedCall.localStream
: await getLocalMediaStream(inactiveCallMediaSources.mic, inactiveCallMediaSources.camera, localCamera);
: await getLocalMediaStream(inactiveCallMediaSources.mic, inactiveCallMediaSources.camera && (await browserHasCamera()), localCamera);
}
catch (e) {
console.log("Error while getting local media stream", e);
@ -296,7 +297,7 @@ const processCommand = (function () {
endCall();
let localStream = null;
try {
localStream = await getLocalMediaStream(true, command.media == CallMediaType.Video, VideoCamera.User);
localStream = await getLocalMediaStream(true, command.media == CallMediaType.Video && (await browserHasCamera()), VideoCamera.User);
const videos = getVideoElements();
if (videos) {
videos.local.srcObject = localStream;
@ -304,6 +305,10 @@ const processCommand = (function () {
}
}
catch (e) {
console.log(e);
// Do not allow to continue the call without audio permission
resp = { type: "error", message: "capabilities: no permissions were granted for mic and/or camera" };
break;
localStream = new MediaStream();
// Will be shown on the next stage of call estabilishing, can work without any streams
//desktopShowPermissionsAlert(command.media)
@ -437,6 +442,11 @@ const processCommand = (function () {
break;
case "media":
if (!activeCall) {
if (!notConnectedCall) {
// call can have a slow startup and be in this place even before "capabilities" stage
resp = { type: "error", message: "media: call has not yet pass capabilities stage" };
break;
}
switch (command.source) {
case CallMediaSource.Mic:
inactiveCallMediaSources.mic = command.enable;
@ -484,8 +494,11 @@ const processCommand = (function () {
if (!activeCall || !pc) {
if (notConnectedCall) {
recreateLocalStreamWhileNotConnected(command.camera);
resp = { type: "ok" };
}
else {
resp = { type: "error", message: "camera: call has not yet pass capabilities stage" };
}
resp = { type: "ok" };
}
else {
if (await replaceMedia(activeCall, CallMediaSource.Camera, true, command.camera)) {
@ -515,6 +528,10 @@ const processCommand = (function () {
endCall();
resp = { type: "ok" };
break;
case "permission":
failedToGetPermissions(command.title, permissionDescription(command));
resp = { type: "ok" };
break;
default:
resp = { type: "error", message: "unknown command" };
break;
@ -884,7 +901,7 @@ const processCommand = (function () {
localStream.getVideoTracks().forEach((elem) => elem.stop());
localStream.getVideoTracks().forEach((elem) => localStream.removeTrack(elem));
}
await getLocalMediaStream(inactiveCallMediaSources.mic && localStream.getAudioTracks().length == 0, inactiveCallMediaSources.camera && (localStream.getVideoTracks().length == 0 || oldCamera != newCamera), newCamera)
await getLocalMediaStream(inactiveCallMediaSources.mic && localStream.getAudioTracks().length == 0, inactiveCallMediaSources.camera && (localStream.getVideoTracks().length == 0 || oldCamera != newCamera) && (await browserHasCamera()), newCamera)
.then((stream) => {
stream.getTracks().forEach((elem) => {
localStream.addTrack(elem);
@ -1074,6 +1091,18 @@ const processCommand = (function () {
};
return navigator.mediaDevices.getDisplayMedia(constraints);
}
async function browserHasCamera() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const hasCamera = devices.some((elem) => elem.kind == "videoinput");
console.log("Camera is available: " + hasCamera);
return hasCamera;
}
catch (error) {
console.log("Error while enumerating devices: " + error, error);
return false;
}
}
function callMediaConstraints(mic, camera, facingMode) {
return {
audio: mic,
@ -1287,6 +1316,18 @@ function desktopShowPermissionsAlert(mediaType) {
window.alert("Permissions denied. Please, allow access to mic and camera to make the call working and hit unmute/camera button. Don't reload the page.");
}
}
function permissionDescription(command) {
if (window.safari) {
return command.safari;
}
else if ((navigator.userAgent.includes("Chrome") && navigator.vendor.includes("Google Inc")) ||
navigator.userAgent.includes("Firefox")) {
return command.chrome;
}
else {
return "";
}
}
// Cryptography function - it is loaded both in the main window and in worker context (if the worker is used)
function callCryptoFunction() {
const initialPlainTextRequired = {

View file

@ -50,6 +50,12 @@
<div id="audio-call-icon">
<img src="/desktop/images/ic_phone_in_talk.svg" />
</div>
<div id="permission-denied">
<p id="permission-denied-title"></p>
<p id="permission-denied-desc"></p>
</div>
<p id="manage-call">
<button id="toggle-screen" onclick="javascript:toggleScreenManually()">
<img src="/desktop/images/ic_screen_share.svg" />

View file

@ -3,6 +3,7 @@ body {
padding: 0;
margin: 0;
background-color: black;
font-family: Arial, Helvetica, sans-serif;
}
#remote-video-stream.inline {
@ -157,6 +158,23 @@ body {
animation: spin 2s linear infinite;
}
#permission-denied {
position: absolute;
width: 100%;
text-align: center;
height: 100%;
align-content: center;
color: white;
}
#permission-denied-title {
font-weight: bold;
}
#permission-denied-desc {
font-style: italic;
}
@-webkit-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
@ -180,7 +198,6 @@ body {
color: white;
opacity: 0.8;
width: 200px;
font-family: Arial, Helvetica, sans-serif;
}
#info-block.audio {

View file

@ -75,6 +75,16 @@ inactiveCallMediaSourcesChanged = (inactiveCallMediaSources) => {
document.getElementById("info-block").className = className;
// document.getElementById("media-sources")!.innerText = inactiveCallMediaSourcesStatus(inactiveCallMediaSources)
};
failedToGetPermissions = (title, description) => {
document.getElementById("info-block").style.visibility = "hidden";
document.getElementById("progress").style.visibility = "hidden";
document.getElementById("permission-denied-title").innerText = title;
document.getElementById("permission-denied-desc").innerText = description;
document.getElementById("toggle-mic").style.visibility = "hidden";
document.getElementById("toggle-camera").style.visibility = "hidden";
document.getElementById("toggle-screen").style.visibility = "hidden";
document.getElementById("toggle-speaker").style.visibility = "hidden";
};
function enableMicIcon(enabled) {
document.getElementById("toggle-mic").innerHTML = enabled
? '<img src="/desktop/images/ic_mic.svg" />'

View file

@ -105,6 +105,14 @@ actual fun ActiveCallView() {
else -> {}
}
is WCallResponse.Error -> {
when (apiMsg.command) {
is WCallCommand.Capabilities -> chatModel.callCommand.add(WCallCommand.Permission(
title = generalGetString(MR.strings.call_desktop_permission_denied_title),
chrome = generalGetString(MR.strings.call_desktop_permission_denied_chrome),
safari = generalGetString(MR.strings.call_desktop_permission_denied_safari)
))
else -> {}
}
Log.e(TAG, "ActiveCallView: command error ${r.message}")
}
}

View file

@ -9,6 +9,7 @@ interface WVApiMessage {
type WCallCommand =
| WCCapabilities
| WCPermission
| WCStartCall
| WCAcceptOffer
| WCallAnswer
@ -33,7 +34,18 @@ type WCallResponse =
| WRError
| WCAcceptOffer
type WCallCommandTag = "capabilities" | "start" | "offer" | "answer" | "ice" | "media" | "camera" | "description" | "layout" | "end"
type WCallCommandTag =
| "capabilities"
| "permission"
| "start"
| "offer"
| "answer"
| "ice"
| "media"
| "camera"
| "description"
| "layout"
| "end"
type WCallResponseTag =
| "capabilities"
@ -154,6 +166,13 @@ interface WCLayout extends IWCallCommand {
layout: LayoutType
}
interface WCPermission extends IWCallCommand {
type: "permission"
title: string
chrome: string
safari: string
}
interface WRCapabilities extends IWCallResponse {
type: "capabilities"
capabilities: CallCapabilities
@ -220,6 +239,7 @@ var sendMessageToNative = (msg: WVApiMessage) => console.log(JSON.stringify(msg)
var toggleScreenShare = async () => {}
var localOrPeerMediaSourcesChanged = (_call: Call) => {}
var inactiveCallMediaSourcesChanged = (_inactiveCallMediaSources: CallMediaSources) => {}
var failedToGetPermissions = (_title: string, _description: string) => {}
// Global object with cryptrographic/encoding functions
const callCrypto = callCryptoFunction()
@ -421,7 +441,11 @@ const processCommand = (function () {
try {
localStream = notConnectedCall?.localStream
? notConnectedCall.localStream
: await getLocalMediaStream(inactiveCallMediaSources.mic, inactiveCallMediaSources.camera, localCamera)
: await getLocalMediaStream(
inactiveCallMediaSources.mic,
inactiveCallMediaSources.camera && (await browserHasCamera()),
localCamera
)
} catch (e) {
console.log("Error while getting local media stream", e)
if (isDesktop) {
@ -560,13 +584,21 @@ const processCommand = (function () {
let localStream: MediaStream | null = null
try {
localStream = await getLocalMediaStream(true, command.media == CallMediaType.Video, VideoCamera.User)
localStream = await getLocalMediaStream(
true,
command.media == CallMediaType.Video && (await browserHasCamera()),
VideoCamera.User
)
const videos = getVideoElements()
if (videos) {
videos.local.srcObject = localStream
videos.local.play().catch((e) => console.log(e))
}
} catch (e) {
console.log(e)
// Do not allow to continue the call without audio permission
resp = {type: "error", message: "capabilities: no permissions were granted for mic and/or camera"}
break
localStream = new MediaStream()
// Will be shown on the next stage of call estabilishing, can work without any streams
//desktopShowPermissionsAlert(command.media)
@ -698,6 +730,11 @@ const processCommand = (function () {
break
case "media":
if (!activeCall) {
if (!notConnectedCall) {
// call can have a slow startup and be in this place even before "capabilities" stage
resp = {type: "error", message: "media: call has not yet pass capabilities stage"}
break
}
switch (command.source) {
case CallMediaSource.Mic:
inactiveCallMediaSources.mic = command.enable
@ -741,8 +778,10 @@ const processCommand = (function () {
if (!activeCall || !pc) {
if (notConnectedCall) {
recreateLocalStreamWhileNotConnected(command.camera)
resp = {type: "ok"}
} else {
resp = {type: "error", message: "camera: call has not yet pass capabilities stage"}
}
resp = {type: "ok"}
} else {
if (await replaceMedia(activeCall, CallMediaSource.Camera, true, command.camera)) {
resp = {type: "ok"}
@ -769,6 +808,10 @@ const processCommand = (function () {
endCall()
resp = {type: "ok"}
break
case "permission":
failedToGetPermissions(command.title, permissionDescription(command))
resp = {type: "ok"}
break
default:
resp = {type: "error", message: "unknown command"}
break
@ -1166,7 +1209,7 @@ const processCommand = (function () {
}
await getLocalMediaStream(
inactiveCallMediaSources.mic && localStream.getAudioTracks().length == 0,
inactiveCallMediaSources.camera && (localStream.getVideoTracks().length == 0 || oldCamera != newCamera),
inactiveCallMediaSources.camera && (localStream.getVideoTracks().length == 0 || oldCamera != newCamera) && (await browserHasCamera()),
newCamera
)
.then((stream) => {
@ -1358,6 +1401,18 @@ const processCommand = (function () {
return navigator.mediaDevices.getDisplayMedia(constraints)
}
async function browserHasCamera(): Promise<boolean> {
try {
const devices = await navigator.mediaDevices.enumerateDevices()
const hasCamera = devices.some((elem) => elem.kind == "videoinput")
console.log("Camera is available: " + hasCamera)
return hasCamera
} catch (error) {
console.log("Error while enumerating devices: " + error, error)
return false
}
}
function callMediaConstraints(mic: boolean, camera: boolean, facingMode: VideoCamera): MediaStreamConstraints {
return {
audio: mic,
@ -1590,6 +1645,19 @@ function desktopShowPermissionsAlert(mediaType: CallMediaType) {
}
}
function permissionDescription(command: WCPermission): string {
if ((window as any).safari) {
return command.safari
} else if (
(navigator.userAgent.includes("Chrome") && navigator.vendor.includes("Google Inc")) ||
navigator.userAgent.includes("Firefox")
) {
return command.chrome
} else {
return ""
}
}
type TransformFrameFunc = (key: CryptoKey) => (frame: RTCEncodedVideoFrame, controller: TransformStreamDefaultController) => Promise<void>
interface CallCrypto {

View file

@ -50,6 +50,12 @@
<div id="audio-call-icon">
<img src="/desktop/images/ic_phone_in_talk.svg" />
</div>
<div id="permission-denied">
<p id="permission-denied-title"></p>
<p id="permission-denied-desc"></p>
</div>
<p id="manage-call">
<button id="toggle-screen" onclick="javascript:toggleScreenManually()">
<img src="/desktop/images/ic_screen_share.svg" />

View file

@ -3,6 +3,7 @@ body {
padding: 0;
margin: 0;
background-color: black;
font-family: Arial, Helvetica, sans-serif;
}
#remote-video-stream.inline {
@ -157,6 +158,23 @@ body {
animation: spin 2s linear infinite;
}
#permission-denied {
position: absolute;
width: 100%;
text-align: center;
height: 100%;
align-content: center;
color: white;
}
#permission-denied-title {
font-weight: bold;
}
#permission-denied-desc {
font-style: italic;
}
@-webkit-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
@ -180,7 +198,6 @@ body {
color: white;
opacity: 0.8;
width: 200px;
font-family: Arial, Helvetica, sans-serif;
}
#info-block.audio {

View file

@ -91,6 +91,19 @@ inactiveCallMediaSourcesChanged = (inactiveCallMediaSources: CallMediaSources) =
// document.getElementById("media-sources")!.innerText = inactiveCallMediaSourcesStatus(inactiveCallMediaSources)
}
failedToGetPermissions = (title: string, description: string) => {
document.getElementById("info-block")!.style.visibility = "hidden"
document.getElementById("progress")!.style.visibility = "hidden"
document.getElementById("permission-denied-title")!.innerText = title
document.getElementById("permission-denied-desc")!.innerText = description
document.getElementById("toggle-mic")!.style.visibility = "hidden"
document.getElementById("toggle-camera")!.style.visibility = "hidden"
document.getElementById("toggle-screen")!.style.visibility = "hidden"
document.getElementById("toggle-speaker")!.style.visibility = "hidden"
}
function enableMicIcon(enabled: boolean) {
document.getElementById("toggle-mic")!.innerHTML = enabled
? '<img src="/desktop/images/ic_mic.svg" />'