| /* |
| * Copyright (C) 2019 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| function createDataChannel(pc, label, onMessage) { |
| console.debug('creating data channel: ' + label); |
| let dataChannel = pc.createDataChannel(label); |
| dataChannel.binaryType = "arraybuffer"; |
| // Return an object with a send function like that of the dataChannel, but |
| // that only actually sends over the data channel once it has connected. |
| return { |
| channelPromise: new Promise((resolve, reject) => { |
| dataChannel.onopen = (event) => { |
| resolve(dataChannel); |
| }; |
| dataChannel.onclose = () => { |
| console.debug( |
| 'Data channel=' + label + ' state=' + dataChannel.readyState); |
| }; |
| dataChannel.onmessage = onMessage ? onMessage : (msg) => { |
| console.debug('Data channel=' + label + ' data="' + msg.data + '"'); |
| }; |
| dataChannel.onerror = err => { |
| reject(err); |
| }; |
| }), |
| send: function(msg) { |
| this.channelPromise = this.channelPromise.then(channel => { |
| channel.send(msg); |
| return channel; |
| }) |
| }, |
| }; |
| } |
| |
| function awaitDataChannel(pc, label, onMessage) { |
| console.debug('expecting data channel: ' + label); |
| // Return an object with a send function like that of the dataChannel, but |
| // that only actually sends over the data channel once it has connected. |
| return { |
| channelPromise: new Promise((resolve, reject) => { |
| let prev_ondatachannel = pc.ondatachannel; |
| pc.ondatachannel = ev => { |
| let dataChannel = ev.channel; |
| if (dataChannel.label == label) { |
| dataChannel.onopen = (event) => { |
| resolve(dataChannel); |
| }; |
| dataChannel.onclose = () => { |
| console.debug( |
| 'Data channel=' + label + ' state=' + dataChannel.readyState); |
| }; |
| dataChannel.onmessage = onMessage ? onMessage : (msg) => { |
| console.debug('Data channel=' + label + ' data="' + msg.data + '"'); |
| }; |
| dataChannel.onerror = err => { |
| reject(err); |
| }; |
| } else if (prev_ondatachannel) { |
| prev_ondatachannel(ev); |
| } |
| }; |
| }), |
| send: function(msg) { |
| this.channelPromise = this.channelPromise.then(channel => { |
| channel.send(msg); |
| return channel; |
| }) |
| }, |
| }; |
| } |
| |
| class DeviceConnection { |
| #pc; |
| #control; |
| #description; |
| |
| #cameraDataChannel; |
| #cameraInputQueue; |
| #controlChannel; |
| #inputChannel; |
| #adbChannel; |
| #bluetoothChannel; |
| #lightsChannel; |
| #locationChannel; |
| #sensorsChannel; |
| #kmlLocationsChannel; |
| #gpxLocationsChannel; |
| |
| #streams; |
| #streamPromiseResolvers; |
| #streamChangeCallback; |
| #micSenders = []; |
| #cameraSenders = []; |
| #camera_res_x; |
| #camera_res_y; |
| |
| #onAdbMessage; |
| #onControlMessage; |
| #onBluetoothMessage; |
| #onSensorsMessage |
| #onLocationMessage; |
| #onKmlLocationsMessage; |
| #onGpxLocationsMessage; |
| #onLightsMessage; |
| |
| #micRequested = false; |
| #cameraRequested = false; |
| |
| constructor(pc, control) { |
| this.#pc = pc; |
| this.#control = control; |
| this.#cameraDataChannel = pc.createDataChannel('camera-data-channel'); |
| this.#cameraDataChannel.binaryType = 'arraybuffer'; |
| this.#cameraInputQueue = new Array(); |
| var self = this; |
| this.#cameraDataChannel.onbufferedamountlow = () => { |
| if (self.#cameraInputQueue.length > 0) { |
| self.sendCameraData(self.#cameraInputQueue.shift()); |
| } |
| }; |
| this.#inputChannel = createDataChannel(pc, 'input-channel'); |
| this.#sensorsChannel = createDataChannel(pc, 'sensors-channel', (msg) => { |
| if (!this.#onSensorsMessage) { |
| console.error('Received unexpected Sensors message'); |
| return; |
| } |
| this.#onSensorsMessage(msg); |
| }); |
| this.#adbChannel = createDataChannel(pc, 'adb-channel', (msg) => { |
| if (!this.#onAdbMessage) { |
| console.error('Received unexpected ADB message'); |
| return; |
| } |
| this.#onAdbMessage(msg.data); |
| }); |
| this.#controlChannel = awaitDataChannel(pc, 'device-control', (msg) => { |
| if (!this.#onControlMessage) { |
| console.error('Received unexpected Control message'); |
| return; |
| } |
| this.#onControlMessage(msg); |
| }); |
| this.#bluetoothChannel = |
| createDataChannel(pc, 'bluetooth-channel', (msg) => { |
| if (!this.#onBluetoothMessage) { |
| console.error('Received unexpected Bluetooth message'); |
| return; |
| } |
| this.#onBluetoothMessage(msg.data); |
| }); |
| this.#locationChannel = |
| createDataChannel(pc, 'location-channel', (msg) => { |
| if (!this.#onLocationMessage) { |
| console.error('Received unexpected Location message'); |
| return; |
| } |
| this.#onLocationMessage(msg.data); |
| }); |
| |
| this.#kmlLocationsChannel = |
| createDataChannel(pc, 'kml-locations-channel', (msg) => { |
| if (!this.#onKmlLocationsMessage) { |
| console.error('Received unexpected KML Locations message'); |
| return; |
| } |
| this.#onKmlLocationsMessage(msg.data); |
| }); |
| |
| this.#gpxLocationsChannel = |
| createDataChannel(pc, 'gpx-locations-channel', (msg) => { |
| if (!this.#onGpxLocationsMessage) { |
| console.error('Received unexpected KML Locations message'); |
| return; |
| } |
| this.#onGpxLocationsMessage(msg.data); |
| }); |
| this.#lightsChannel = createDataChannel(pc, 'lights-channel', (msg) => { |
| if (!this.#onLightsMessage) { |
| console.error('Received unexpected Lights message'); |
| return; |
| } |
| this.#onLightsMessage(msg); |
| }); |
| |
| this.#streams = {}; |
| this.#streamPromiseResolvers = {}; |
| |
| pc.addEventListener('track', e => { |
| console.debug('Got remote stream: ', e); |
| for (const stream of e.streams) { |
| this.#streams[stream.id] = stream; |
| if (this.#streamPromiseResolvers[stream.id]) { |
| for (let resolver of this.#streamPromiseResolvers[stream.id]) { |
| resolver(); |
| } |
| delete this.#streamPromiseResolvers[stream.id]; |
| } |
| |
| if (this.#streamChangeCallback) { |
| this.#streamChangeCallback(stream); |
| } |
| } |
| }); |
| } |
| |
| set description(desc) { |
| this.#description = desc; |
| } |
| |
| get description() { |
| return this.#description; |
| } |
| |
| get imageCapture() { |
| if (this.#cameraSenders && this.#cameraSenders.length > 0) { |
| let track = this.#cameraSenders[0].track; |
| return new ImageCapture(track); |
| } |
| return undefined; |
| } |
| |
| get cameraWidth() { |
| return this.#camera_res_x; |
| } |
| |
| get cameraHeight() { |
| return this.#camera_res_y; |
| } |
| |
| get cameraEnabled() { |
| return this.#cameraSenders && this.#cameraSenders.length > 0; |
| } |
| |
| getStream(stream_id) { |
| if (stream_id in this.#streams) { |
| return this.#streams[stream_id]; |
| } |
| return null; |
| } |
| |
| onStream(stream_id) { |
| return new Promise((resolve, reject) => { |
| if (this.#streams[stream_id]) { |
| resolve(this.#streams[stream_id]); |
| } else { |
| if (!this.#streamPromiseResolvers[stream_id]) { |
| this.#streamPromiseResolvers[stream_id] = []; |
| } |
| this.#streamPromiseResolvers[stream_id].push(resolve); |
| } |
| }); |
| } |
| |
| onStreamChange(cb) { |
| this.#streamChangeCallback = cb; |
| } |
| |
| expectStreamChange() { |
| this.#control.expectMessagesSoon(5000); |
| } |
| |
| #sendJsonInput(evt) { |
| this.#inputChannel.send(JSON.stringify(evt)); |
| } |
| |
| sendMouseMove({x, y}) { |
| this.#sendJsonInput({ |
| type: 'mouseMove', |
| x, |
| y, |
| }); |
| } |
| |
| sendMouseButton({button, down}) { |
| this.#sendJsonInput({ |
| type: 'mouseButton', |
| button: button, |
| down: down ? 1 : 0, |
| }); |
| } |
| |
| // TODO (b/124121375): This should probably be an array of pointer events and |
| // have different properties. |
| sendMultiTouch({idArr, xArr, yArr, down, device_label}) { |
| let events = { |
| type: 'multi-touch', |
| id: idArr, |
| x: xArr, |
| y: yArr, |
| down: down ? 1 : 0, |
| device_label: device_label, |
| }; |
| this.#sendJsonInput(events); |
| } |
| |
| sendKeyEvent(code, type) { |
| this.#sendJsonInput({type: 'keyboard', keycode: code, event_type: type}); |
| } |
| |
| sendWheelEvent(pixels) { |
| this.#sendJsonInput({ |
| type: 'wheel', |
| // convert double to int, forcing a base 10 conversion. pixels can be fractional. |
| pixels: parseInt(pixels, 10), |
| }); |
| } |
| |
| sendMouseWheelEvent(pixels) { |
| this.#sendJsonInput({ |
| type: 'mouseWheel', |
| // convert double to int, forcing a base 10 conversion. pixels can be fractional. |
| pixels: parseInt(pixels, 10), |
| }); |
| } |
| |
| disconnect() { |
| this.#pc.close(); |
| } |
| |
| // Sends binary data directly to the in-device adb daemon (skipping the host) |
| sendAdbMessage(msg) { |
| this.#adbChannel.send(msg); |
| } |
| |
| // Provide a callback to receive data from the in-device adb daemon |
| onAdbMessage(cb) { |
| this.#onAdbMessage = cb; |
| } |
| |
| // Send control commands to the device |
| sendControlMessage(msg) { |
| this.#controlChannel.send(msg); |
| } |
| |
| async #useDevice(in_use, senders_arr, device_opt, requestedFn = () => {in_use}, |
| enabledFn = (stream) => {}, disabledFn = () => {}) { |
| // An empty array means no tracks are currently in use |
| if (senders_arr.length > 0 === !!in_use) { |
| return in_use; |
| } |
| let renegotiation_needed = false; |
| if (in_use) { |
| try { |
| let stream = await navigator.mediaDevices.getUserMedia(device_opt); |
| // The user may have changed their mind by the time we obtain the |
| // stream, check again |
| if (!!in_use != requestedFn()) { |
| return requestedFn(); |
| } |
| enabledFn(stream); |
| stream.getTracks().forEach(track => { |
| console.info(`Using ${track.kind} device: ${track.label}`); |
| senders_arr.push(this.#pc.addTrack(track)); |
| renegotiation_needed = true; |
| }); |
| } catch (e) { |
| console.error('Failed to add stream to peer connection: ', e); |
| // Don't return yet, if there were errors some tracks may have been |
| // added so the connection should be renegotiated again. |
| } |
| } else { |
| for (const sender of senders_arr) { |
| console.info( |
| `Removing ${sender.track.kind} device: ${sender.track.label}`); |
| let track = sender.track; |
| track.stop(); |
| this.#pc.removeTrack(sender); |
| renegotiation_needed = true; |
| } |
| // Empty the array passed by reference, just assigning [] won't do that. |
| senders_arr.length = 0; |
| disabledFn(); |
| } |
| if (renegotiation_needed) { |
| await this.#control.renegotiateConnection(); |
| } |
| // Return the new state |
| return senders_arr.length > 0; |
| } |
| |
| // enabledFn: a callback function that will be called if the mic is successfully enabled. |
| // disabledFn: a callback function that will be called if the mic is successfully disabled. |
| async useMic(in_use, enabledFn = () => {}, disabledFn = () => {}) { |
| if (this.#micRequested == !!in_use) { |
| return in_use; |
| } |
| this.#micRequested = !!in_use; |
| return this.#useDevice( |
| in_use, this.#micSenders, {audio: true, video: false}, |
| () => this.#micRequested, |
| enabledFn, |
| disabledFn); |
| } |
| |
| async useCamera(in_use) { |
| if (this.#cameraRequested == !!in_use) { |
| return in_use; |
| } |
| this.#cameraRequested = !!in_use; |
| return this.#useDevice( |
| in_use, this.#micSenders, {audio: false, video: true}, |
| () => this.#cameraRequested, |
| (stream) => this.sendCameraResolution(stream)); |
| } |
| |
| sendCameraResolution(stream) { |
| const cameraTracks = stream.getVideoTracks(); |
| if (cameraTracks.length > 0) { |
| const settings = cameraTracks[0].getSettings(); |
| this.#camera_res_x = settings.width; |
| this.#camera_res_y = settings.height; |
| this.sendControlMessage(JSON.stringify({ |
| command: 'camera_settings', |
| width: settings.width, |
| height: settings.height, |
| frame_rate: settings.frameRate, |
| facing: settings.facingMode |
| })); |
| } |
| } |
| |
| sendOrQueueCameraData(data) { |
| if (this.#cameraDataChannel.bufferedAmount > 0 || |
| this.#cameraInputQueue.length > 0) { |
| this.#cameraInputQueue.push(data); |
| } else { |
| this.sendCameraData(data); |
| } |
| } |
| |
| sendCameraData(data) { |
| const MAX_SIZE = 65535; |
| const END_MARKER = 'EOF'; |
| for (let i = 0; i < data.byteLength; i += MAX_SIZE) { |
| // range is clamped to the valid index range |
| this.#cameraDataChannel.send(data.slice(i, i + MAX_SIZE)); |
| } |
| this.#cameraDataChannel.send(END_MARKER); |
| } |
| |
| // Provide a callback to receive control-related comms from the device |
| onControlMessage(cb) { |
| this.#onControlMessage = cb; |
| } |
| |
| sendBluetoothMessage(msg) { |
| this.#bluetoothChannel.send(msg); |
| } |
| |
| onBluetoothMessage(cb) { |
| this.#onBluetoothMessage = cb; |
| } |
| |
| sendLocationMessage(msg) { |
| this.#locationChannel.send(msg); |
| } |
| |
| sendSensorsMessage(msg) { |
| this.#sensorsChannel.send(msg); |
| } |
| |
| onSensorsMessage(cb) { |
| this.#onSensorsMessage = cb; |
| } |
| |
| onLocationMessage(cb) { |
| this.#onLocationMessage = cb; |
| } |
| |
| sendKmlLocationsMessage(msg) { |
| this.#kmlLocationsChannel.send(msg); |
| } |
| |
| onKmlLocationsMessage(cb) { |
| this.#kmlLocationsChannel = cb; |
| } |
| |
| sendGpxLocationsMessage(msg) { |
| this.#gpxLocationsChannel.send(msg); |
| } |
| |
| onGpxLocationsMessage(cb) { |
| this.#gpxLocationsChannel = cb; |
| } |
| |
| // Provide a callback to receive connectionstatechange states. |
| onConnectionStateChange(cb) { |
| this.#pc.addEventListener( |
| 'connectionstatechange', evt => cb(this.#pc.connectionState)); |
| } |
| |
| onLightsMessage(cb) { |
| this.#onLightsMessage = cb; |
| } |
| } |
| |
| class Controller { |
| #pc; |
| #serverConnector; |
| #connectedPr = Promise.resolve({}); |
| // A list of callbacks that need to be called when the remote description is |
| // successfully added to the peer connection. |
| #onRemoteDescriptionSetCbs = []; |
| |
| constructor(serverConnector) { |
| this.#serverConnector = serverConnector; |
| serverConnector.onDeviceMsg(msg => this.#onDeviceMessage(msg)); |
| } |
| |
| #onDeviceMessage(message) { |
| let type = message.type; |
| switch (type) { |
| case 'offer': |
| this.#onOffer({type: 'offer', sdp: message.sdp}); |
| break; |
| case 'answer': |
| this.#onRemoteDescription({type: 'answer', sdp: message.sdp}); |
| break; |
| case 'ice-candidate': |
| this.#onIceCandidate(new RTCIceCandidate({ |
| sdpMid: message.mid, |
| sdpMLineIndex: message.mLineIndex, |
| candidate: message.candidate |
| })); |
| break; |
| case 'error': |
| console.error('Device responded with error message: ', message.error); |
| break; |
| default: |
| console.error('Unrecognized message type from device: ', type); |
| } |
| } |
| |
| async #sendClientDescription(desc) { |
| console.debug('sendClientDescription'); |
| return this.#serverConnector.sendToDevice({type: 'answer', sdp: desc.sdp}); |
| } |
| |
| async #sendIceCandidate(candidate) { |
| console.debug('sendIceCandidate'); |
| return this.#serverConnector.sendToDevice({type: 'ice-candidate', candidate}); |
| } |
| |
| async #onOffer(desc) { |
| try { |
| await this.#onRemoteDescription(desc); |
| let answer = await this.#pc.createAnswer(); |
| console.debug('Answer: ', answer); |
| await this.#pc.setLocalDescription(answer); |
| await this.#sendClientDescription(answer); |
| } catch (e) { |
| console.error('Error processing remote description (offer)', e) |
| throw e; |
| } |
| } |
| |
| async #onRemoteDescription(desc) { |
| console.debug(`Remote description (${desc.type}): `, desc); |
| try { |
| await this.#pc.setRemoteDescription(desc); |
| for (const cb of this.#onRemoteDescriptionSetCbs) { |
| cb(); |
| } |
| this.#onRemoteDescriptionSetCbs = []; |
| } catch (e) { |
| console.error(`Error processing remote description (${desc.type})`, e) |
| throw e; |
| } |
| } |
| |
| #onIceCandidate(iceCandidate) { |
| console.debug(`Remote ICE Candidate: `, iceCandidate); |
| this.#pc.addIceCandidate(iceCandidate); |
| } |
| |
| expectMessagesSoon(durationMilliseconds) { |
| if (this.#serverConnector.expectMessagesSoon) { |
| this.#serverConnector.expectMessagesSoon(durationMilliseconds); |
| } else { |
| console.warn(`Unavailable expectMessagesSoon(). Messages may be slow.`); |
| } |
| } |
| |
| // This effectively ensures work that changes connection state doesn't run |
| // concurrently. |
| // Returns a promise that resolves if the connection is successfully |
| // established after the provided work is done. |
| #onReadyToNegotiate(work_cb) { |
| const connectedPr = this.#connectedPr.then(() => { |
| const controller = new AbortController(); |
| const pr = new Promise((resolve, reject) => { |
| // The promise resolves when the connection changes state to 'connected' |
| // or when a remote description is set and the connection was already in |
| // 'connected' state. |
| this.#onRemoteDescriptionSetCbs.push(() => { |
| if (this.#pc.connectionState == 'connected') { |
| resolve({}); |
| } |
| }); |
| this.#pc.addEventListener('connectionstatechange', evt => { |
| let state = this.#pc.connectionState; |
| if (state == 'connected') { |
| resolve(evt); |
| } else if (state == 'failed') { |
| reject(evt); |
| } |
| }, {signal: controller.signal}); |
| }); |
| // Remove the listener once the promise fulfills. |
| pr.finally(() => controller.abort()); |
| work_cb(); |
| // Don't return pr.finally() since that is never rejected. |
| return pr; |
| }); |
| // A failure is also a sign that renegotiation is possible again |
| this.#connectedPr = connectedPr.catch(_ => {}); |
| return connectedPr; |
| } |
| |
| async ConnectDevice(pc, infraConfig) { |
| this.#pc = pc; |
| console.debug('ConnectDevice'); |
| // ICE candidates will be generated when we add the offer. Adding it here |
| // instead of in #onOffer because this function is called once per peer |
| // connection, while #onOffer may be called more than once due to |
| // renegotiations. |
| this.#pc.addEventListener('icecandidate', evt => { |
| // The last candidate is null, which indicates the end of ICE gathering. |
| // Firefox's second to last candidate has the candidate property set to |
| // empty, skip that one. |
| if (evt.candidate && evt.candidate.candidate) { |
| this.#sendIceCandidate(evt.candidate); |
| } |
| }); |
| return this.#onReadyToNegotiate(_ => { |
| this.#serverConnector.sendToDevice( |
| {type: 'request-offer', ice_servers: infraConfig.ice_servers}); |
| }); |
| } |
| |
| async renegotiateConnection() { |
| return this.#onReadyToNegotiate(async () => { |
| console.debug('Re-negotiating connection'); |
| let offer = await this.#pc.createOffer(); |
| console.debug('Local description (offer): ', offer); |
| await this.#pc.setLocalDescription(offer); |
| await this.#serverConnector.sendToDevice({type: 'offer', sdp: offer.sdp}); |
| }); |
| } |
| } |
| |
| function createPeerConnection(infra_config) { |
| let pc_config = {iceServers: infra_config.ice_servers}; |
| let pc = new RTCPeerConnection(pc_config); |
| |
| pc.addEventListener('icecandidate', evt => { |
| console.debug('Local ICE Candidate: ', evt.candidate); |
| }); |
| pc.addEventListener('iceconnectionstatechange', evt => { |
| console.debug(`ICE State Change: ${pc.iceConnectionState}`); |
| }); |
| pc.addEventListener( |
| 'connectionstatechange', |
| evt => console.debug( |
| `WebRTC Connection State Change: ${pc.connectionState}`)); |
| return pc; |
| } |
| |
| export async function Connect(deviceId, serverConnector) { |
| let requestRet = await serverConnector.requestDevice(deviceId); |
| let deviceInfo = requestRet.deviceInfo; |
| let infraConfig = requestRet.infraConfig; |
| console.debug('Device available:'); |
| console.debug(deviceInfo); |
| let pc = createPeerConnection(infraConfig); |
| |
| let control = new Controller(serverConnector); |
| let deviceConnection = new DeviceConnection(pc, control); |
| deviceConnection.description = deviceInfo; |
| |
| return control.ConnectDevice(pc, infraConfig).then(_ => deviceConnection); |
| } |