1/*
2 * Copyright (C) 2021 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17'use strict';
18
19// The public elements in this file implement the Server Connector Interface,
20// part of the contract between the signaling server and the webrtc client.
21// No changes that break backward compatibility are allowed here. Any new
22// features must be added as a new function/class in the interface. Any
23// additions to the interface must be checked for existence by the client before
24// using it.
25
26// The id of the device the client is supposed to connect to.
27// The List Devices page in the signaling server may choose any way to pass the
28// device id to the client page, this function retrieves that information once
29// the client loaded.
30// In this case the device id is passed as a parameter in the url.
31export function deviceId() {
32  const urlParams = new URLSearchParams(window.location.search);
33  return urlParams.get('deviceId');
34}
35
36// Creates a connector capable of communicating with the signaling server.
37export async function createConnector() {
38  try {
39    let ws = await connectWs();
40    console.debug(`Connected to ${ws.url}`);
41    return new WebsocketConnector(ws);
42  } catch (e) {
43    console.error('WebSocket error:', e);
44  }
45  console.warn('Failed to connect websocket, trying polling instead');
46
47  return new PollingConnector();
48}
49
50// A connector object provides high level functions for communicating with the
51// signaling server, while hiding away implementation details.
52// This class is an interface and shouldn't be instantiated directly.
53// Only the public methods present in this class form part of the Server
54// Connector Interface, any implementations of the interface are considered
55// internal and not accessible to client code.
56class Connector {
57  constructor() {
58    if (this.constructor == Connector) {
59      throw new Error('Connector is an abstract class');
60    }
61  }
62
63  // Registers a callback to receive messages from the device. A race may occur
64  // if this is called after requestDevice() is called in which some device
65  // messages are lost.
66  onDeviceMsg(cb) {
67    throw 'Not implemented!';
68  }
69
70  // Selects a particular device in the signaling server and opens the signaling
71  // channel with it (but doesn't send any message to the device). Returns a
72  // promise to an object with the following properties:
73  // - deviceInfo: The info object provided by the device when it registered
74  // with the server.
75  // - infraConfig: The server's infrastructure configuration (mainly STUN and
76  // TURN servers)
77  // The promise may take a long time to resolve if, for example, the server
78  // decides to wait for a device with the provided id to register with it. The
79  // promise may be rejected if there are connectivity issues, a device with
80  // that id doesn't exist or this client doesn't have rights to access that
81  // device.
82  async requestDevice(deviceId) {
83    throw 'Not implemented!';
84  }
85
86  // Sends a message to the device selected with requestDevice. It's an error to
87  // call this function before the promise from requestDevice() has resolved.
88  // Returns an empty promise that is rejected when the message can not be
89  // delivered, either because the device has not been requested yet or because
90  // of connectivity issues.
91  async sendToDevice(msg) {
92    throw 'Not implemented!';
93  }
94
95  // Provides a hint to this controller that it should expect messages from the
96  // signaling server soon. This is useful for a connector which polls for
97  // example which might want to poll more quickly for a period of time.
98  expectMessagesSoon(durationMilliseconds) {
99    throw 'Not implemented!';
100  }
101}
102
103// Returns real implementation for ParentController.
104export function createParentController() {
105  return null;
106}
107
108// ParentController object provides methods for sending information from device
109// UI to operator UI. This class is just an interface and real implementation is
110// at the operator side. This class shouldn't be instantiated directly.
111class ParentController {
112  constructor() {
113    if (this.constructor === ParentController) {
114      throw new Error('ParentController is an abstract class');
115    }
116  }
117
118  // Create and return a message object that contains display information of
119  // device. Created object can be sent to operator UI using send() method.
120  // rotation argument is device's physycan rotation so it will be commonly
121  // applied to all displays.
122  createDeviceDisplaysMessage(rotation) {
123    throw 'Not implemented';
124  }
125}
126
127// This class represents displays information for a device. This message is
128// intended to be sent to operator UI to determine panel size of device UI.
129// This is an abstract class and should not be instantiated directly. This
130// message is created using createDeviceDisplaysMessage method of
131// ParentController. Real implementation of this class is at operator side.
132export class DeviceDisplaysMessage {
133  constructor(parentController, rotation) {
134    if (this.constructor === DeviceDisplaysMessage) {
135      throw new Error('DeviceDisplaysMessage is an abstract class');
136    }
137  }
138
139  // Add a display information to deviceDisplays message.
140  addDisplay(display_id, width, height) {
141    throw 'Not implemented'
142  }
143
144  // Send DeviceDisplaysMessage created using createDeviceDisplaysMessage to
145  // operator UI. If operator UI does not exist (in the case device web page
146  // is opened directly), the message will just be ignored.
147  send() {
148    throw 'Not implemented'
149  }
150}
151
152// End of Server Connector Interface.
153
154// The following code is internal and shouldn't be accessed outside this file.
155
156function httpUrl(path) {
157  return location.protocol + '//' + location.host + '/' + path;
158}
159
160function websocketUrl(path) {
161  return ((location.protocol == 'http:') ? 'ws://' : 'wss://') + location.host +
162      '/' + path;
163}
164
165const kPollConfigUrl = httpUrl('infra_config');
166const kPollConnectUrl = httpUrl('connect');
167const kPollForwardUrl = httpUrl('forward');
168const kPollMessagesUrl = httpUrl('poll_messages');
169
170async function connectWs() {
171  return new Promise((resolve, reject) => {
172    let url = websocketUrl('connect_client');
173    let ws = new WebSocket(url);
174    ws.onopen = () => {
175      resolve(ws);
176    };
177    ws.onerror = evt => {
178      reject(evt);
179    };
180  });
181}
182
183async function ajaxPostJson(url, data) {
184  const response = await fetch(url, {
185    method: 'POST',
186    cache: 'no-cache',
187    headers: {'Content-Type': 'application/json'},
188    redirect: 'follow',
189    body: JSON.stringify(data),
190  });
191  return response.json();
192}
193
194// Implementation of the connector interface using websockets
195class WebsocketConnector extends Connector {
196  #websocket;
197  #futures = {};
198  #onDeviceMsgCb = msg =>
199      console.error('Received device message without registered listener');
200
201  onDeviceMsg(cb) {
202    this.#onDeviceMsgCb = cb;
203  }
204
205  constructor(ws) {
206    super();
207    ws.onmessage = e => {
208      let data = JSON.parse(e.data);
209      this.#onWebsocketMessage(data);
210    };
211    this.#websocket = ws;
212  }
213
214  async requestDevice(deviceId) {
215    return new Promise((resolve, reject) => {
216      this.#futures.onDeviceAvailable = (device) => resolve(device);
217      this.#futures.onConnectionFailed = (error) => reject(error);
218      this.#wsSendJson({
219        message_type: 'connect',
220        device_id: deviceId,
221      });
222    });
223  }
224
225  async sendToDevice(msg) {
226    return this.#wsSendJson({message_type: 'forward', payload: msg});
227  }
228
229  #onWebsocketMessage(message) {
230    const type = message.message_type;
231    if (message.error) {
232      console.error(message.error);
233      this.#futures.onConnectionFailed(message.error);
234      return;
235    }
236    switch (type) {
237      case 'config':
238        this.#futures.infraConfig = message;
239        break;
240      case 'device_info':
241        if (this.#futures.onDeviceAvailable) {
242          this.#futures.onDeviceAvailable({
243            deviceInfo: message.device_info,
244            infraConfig: this.#futures.infraConfig,
245          });
246          delete this.#futures.onDeviceAvailable;
247        } else {
248          console.error('Received unsolicited device info');
249        }
250        break;
251      case 'device_msg':
252        this.#onDeviceMsgCb(message.payload);
253        break;
254      default:
255        console.error('Unrecognized message type from server: ', type);
256        this.#futures.onConnectionFailed(
257            'Unrecognized message type from server: ' + type);
258        console.error(message);
259    }
260  }
261
262  async #wsSendJson(obj) {
263    return this.#websocket.send(JSON.stringify(obj));
264  }
265
266  expectMessagesSoon(durationMilliseconds) {
267    // No-op
268  }
269}
270
271const SHORT_POLL_DELAY = 1000;
272
273// Implementation of the Connector interface using HTTP long polling
274class PollingConnector extends Connector {
275  #connId = undefined;
276  #config = undefined;
277  #pollerSchedule;
278  #pollQuicklyUntil = Date.now();
279  #onDeviceMsgCb = msg =>
280      console.error('Received device message without registered listener');
281
282  onDeviceMsg(cb) {
283    this.#onDeviceMsgCb = cb;
284  }
285
286  constructor() {
287    super();
288  }
289
290  async requestDevice(deviceId) {
291    let config = await this.#getConfig();
292    let response = await ajaxPostJson(kPollConnectUrl, {device_id: deviceId});
293    this.#connId = response.connection_id;
294
295    this.#startPolling();
296
297    return {
298      deviceInfo: response.device_info,
299      infraConfig: config,
300    };
301  }
302
303  async sendToDevice(msg) {
304    // Forward messages act like polling messages as well
305    let device_messages = await this.#forward(msg);
306    for (const message of device_messages) {
307      this.#onDeviceMsgCb(message);
308    }
309  }
310
311  async #getConfig() {
312    if (this.#config === undefined) {
313      this.#config = await (await fetch(kPollConfigUrl, {
314                       method: 'GET',
315                       redirect: 'follow',
316                     })).json();
317    }
318    return this.#config;
319  }
320
321  async #forward(msg) {
322    return await ajaxPostJson(kPollForwardUrl, {
323      connection_id: this.#connId,
324      payload: msg,
325    });
326  }
327
328  async #pollMessages() {
329    return await ajaxPostJson(kPollMessagesUrl, {
330      connection_id: this.#connId,
331    });
332  }
333
334  #calcNextPollDelay(previousPollDelay) {
335    if (Date.now() < this.#pollQuicklyUntil) {
336      return SHORT_POLL_DELAY;
337    } else {
338      // Do exponential backoff on the polling up to 60 seconds
339      return Math.min(60000, 2 * previousPollDelay);
340    }
341  }
342
343  #startPolling() {
344    if (this.#pollerSchedule !== undefined) {
345      return;
346    }
347
348    let currentPollDelay = SHORT_POLL_DELAY;
349    let pollerRoutine = async () => {
350      let messages = await this.#pollMessages();
351
352      currentPollDelay = this.#calcNextPollDelay(currentPollDelay);
353
354      for (const message of messages) {
355        this.#onDeviceMsgCb(message);
356        // There is at least one message, poll sooner
357        currentPollDelay = SHORT_POLL_DELAY;
358      }
359      this.#pollerSchedule = setTimeout(pollerRoutine, currentPollDelay);
360    };
361
362    this.#pollerSchedule = setTimeout(pollerRoutine, currentPollDelay);
363  }
364
365  expectMessagesSoon(durationMilliseconds) {
366    console.debug("Polling frequently for ", durationMilliseconds, " ms.");
367
368    clearTimeout(this.#pollerSchedule);
369    this.#pollerSchedule = undefined;
370
371    this.#pollQuicklyUntil = Date.now() + durationMilliseconds;
372    this.#startPolling();
373  }
374}
375