blob: ffcf1c971ac39fbf4e0db2db71801ff103c5a394 [file] [log] [blame]
vikasmarwaha@webrtc.org98fce152013-02-27 23:22:10 +00001#!/usr/bin/python2.4
2#
3# Copyright 2011 Google Inc. All Rights Reserved.
4
5# pylint: disable-msg=C6310
6
7"""WebRTC Demo
8
9This module demonstrates the WebRTC API by implementing a simple video chat app.
10"""
11
12import cgi
13import datetime
14import logging
15import os
16import random
17import re
18import json
19import jinja2
20import webapp2
21import threading
22from google.appengine.api import channel
23from google.appengine.ext import db
24
25jinja_environment = jinja2.Environment(
26 loader=jinja2.FileSystemLoader(os.path.dirname(__file__)))
27
28# Lock for syncing DB operation in concurrent requests handling.
29# TODO(brave): keeping working on improving performance with thread syncing.
30# One possible method for near future is to reduce the message caching.
31LOCK = threading.RLock()
32
33def generate_random(len):
34 word = ''
35 for i in range(len):
36 word += random.choice('0123456789')
37 return word
38
39def sanitize(key):
40 return re.sub('[^a-zA-Z0-9\-]', '-', key)
41
42def make_client_id(room, user):
43 return room.key().id_or_name() + '/' + user
44
45def make_pc_config(stun_server, turn_server, ts_pwd):
46 servers = []
47 if turn_server:
48 turn_config = 'turn:{}'.format(turn_server)
49 servers.append({'url':turn_config, 'credential':ts_pwd})
50 if stun_server:
51 stun_config = 'stun:{}'.format(stun_server)
52 else:
53 stun_config = 'stun:' + 'stun.l.google.com:19302'
54 servers.append({'url':stun_config})
55 return {'iceServers':servers}
56
57def create_channel(room, user, duration_minutes):
58 client_id = make_client_id(room, user)
59 return channel.create_channel(client_id, duration_minutes)
60
61def make_loopback_answer(message):
62 message = message.replace("\"offer\"", "\"answer\"")
63 message = message.replace("a=ice-options:google-ice\\r\\n", "")
64 return message
65
66def maybe_add_fake_crypto(message):
67 if message.find("a=crypto") == -1:
68 index = len(message)
69 crypto_line = "a=crypto:1 AES_CM_128_HMAC_SHA1_80 inline:BAADBAADBAADBAADBAADBAADBAADBAADBAADBAAD\\r\\n"
70 # reverse find for multiple find and insert operations.
71 index = message.rfind("c=IN", 0, index)
72 while (index != -1):
73 message = message[:index] + crypto_line + message[index:]
74 index = message.rfind("c=IN", 0, index)
75 return message
76
77def handle_message(room, user, message):
78 message_obj = json.loads(message)
79 other_user = room.get_other_user(user)
80 room_key = room.key().id_or_name();
81 if message_obj['type'] == 'bye':
82 # This would remove the other_user in loopback test too.
83 # So check its availability before forwarding Bye message.
84 room.remove_user(user)
85 logging.info('User ' + user + ' quit from room ' + room_key)
86 logging.info('Room ' + room_key + ' has state ' + str(room))
87 if other_user and room.has_user(other_user):
88 if message_obj['type'] == 'offer':
89 # Special case the loopback scenario.
90 if other_user == user:
91 message = make_loopback_answer(message)
92 # Workaround Chrome bug.
93 # Insert a=crypto line into offer from FireFox.
94 # TODO(juberti): Remove this call.
95 message = maybe_add_fake_crypto(message)
96 on_message(room, other_user, message)
97
98def get_saved_messages(client_id):
99 return Message.gql("WHERE client_id = :id", id=client_id)
100
101def delete_saved_messages(client_id):
102 messages = get_saved_messages(client_id)
103 for message in messages:
104 message.delete()
105 logging.info('Deleted the saved message for ' + client_id)
106
107def send_saved_messages(client_id):
108 messages = get_saved_messages(client_id)
109 for message in messages:
110 channel.send_message(client_id, message.msg)
111 logging.info('Delivered saved message to ' + client_id);
112 message.delete()
113
114def on_message(room, user, message):
115 client_id = make_client_id(room, user)
116 if room.is_connected(user):
117 channel.send_message(client_id, message)
118 logging.info('Delivered message to user ' + user);
119 else:
120 new_message = Message(client_id = client_id, msg = message)
121 new_message.put()
122 logging.info('Saved message for user ' + user)
123
124def make_media_constraints(hd_video):
125 constraints = { 'optional': [], 'mandatory': {} }
126 # Demo 16:9 video with media constraints.
127 if hd_video.lower() == 'true':
128 # Demo with WHD by setting size with 1280x720.
129 constraints['mandatory']['minHeight'] = 720
130 constraints['mandatory']['minWidth'] = 1280
131 # Disabled for now due to weird stretching behavior on Mac.
132 #else:
133 # Demo with WVGA by setting Aspect Ration;
134 #constraints['mandatory']['maxAspectRatio'] = 1.778
135 #constraints['mandatory']['minAspectRatio'] = 1.777
136 return constraints
137
138def make_pc_constraints(compat):
139 constraints = { 'optional': [] }
140 # For interop with FireFox. Enable DTLS in peerConnection ctor.
141 if compat.lower() == 'true':
142 constraints['optional'].append({'DtlsSrtpKeyAgreement': True})
143 return constraints
144
145def make_offer_constraints(compat):
146 constraints = { 'mandatory': {}, 'optional': [] }
147 # For interop with FireFox. Disable Data Channel in createOffer.
148 if compat.lower() == 'true':
149 constraints['mandatory']['MozDontOfferDataChannel'] = True
150 return constraints
151
152def append_url_arguments(request, link):
153 for argument in request.arguments():
154 if argument != 'r':
155 link += ('&' + cgi.escape(argument, True) + '=' +
156 cgi.escape(request.get(argument), True))
157 return link
158
159# This database is to store the messages from the sender client when the
160# receiver client is not ready to receive the messages.
161# Use TextProperty instead of StringProperty for msg because
162# the session description can be more than 500 characters.
163class Message(db.Model):
164 client_id = db.StringProperty()
165 msg = db.TextProperty()
166
167class Room(db.Model):
168 """All the data we store for a room"""
169 user1 = db.StringProperty()
170 user2 = db.StringProperty()
171 user1_connected = db.BooleanProperty(default=False)
172 user2_connected = db.BooleanProperty(default=False)
173
174 def __str__(self):
175 str = '['
176 if self.user1:
177 str += "%s-%r" % (self.user1, self.user1_connected)
178 if self.user2:
179 str += ", %s-%r" % (self.user2, self.user2_connected)
180 str += ']'
181 return str
182
183 def get_occupancy(self):
184 occupancy = 0
185 if self.user1:
186 occupancy += 1
187 if self.user2:
188 occupancy += 1
189 return occupancy
190
191 def get_other_user(self, user):
192 if user == self.user1:
193 return self.user2
194 elif user == self.user2:
195 return self.user1
196 else:
197 return None
198
199 def has_user(self, user):
200 return (user and (user == self.user1 or user == self.user2))
201
202 def add_user(self, user):
203 if not self.user1:
204 self.user1 = user
205 elif not self.user2:
206 self.user2 = user
207 else:
208 raise RuntimeError('room is full')
209 self.put()
210
211 def remove_user(self, user):
212 delete_saved_messages(make_client_id(self, user))
213 if user == self.user2:
214 self.user2 = None
215 self.user2_connected = False
216 if user == self.user1:
217 if self.user2:
218 self.user1 = self.user2
219 self.user1_connected = self.user2_connected
220 self.user2 = None
221 self.user2_connected = False
222 else:
223 self.user1 = None
224 self.user1_connected = False
225 if self.get_occupancy() > 0:
226 self.put()
227 else:
228 self.delete()
229
230 def set_connected(self, user):
231 if user == self.user1:
232 self.user1_connected = True
233 if user == self.user2:
234 self.user2_connected = True
235 self.put()
236
237 def is_connected(self, user):
238 if user == self.user1:
239 return self.user1_connected
240 if user == self.user2:
241 return self.user2_connected
242
243class ConnectPage(webapp2.RequestHandler):
244 def post(self):
245 key = self.request.get('from')
246 room_key, user = key.split('/')
247 with LOCK:
248 room = Room.get_by_key_name(room_key)
249 # Check if room has user in case that disconnect message comes before
250 # connect message with unknown reason, observed with local AppEngine SDK.
251 if room and room.has_user(user):
252 room.set_connected(user)
253 send_saved_messages(make_client_id(room, user))
254 logging.info('User ' + user + ' connected to room ' + room_key)
255 logging.info('Room ' + room_key + ' has state ' + str(room))
256 else:
257 logging.warning('Unexpected Connect Message to room ' + room_key)
258
259
260class DisconnectPage(webapp2.RequestHandler):
261 def post(self):
262 key = self.request.get('from')
263 room_key, user = key.split('/')
264 with LOCK:
265 room = Room.get_by_key_name(room_key)
266 if room and room.has_user(user):
267 other_user = room.get_other_user(user)
268 room.remove_user(user)
269 logging.info('User ' + user + ' removed from room ' + room_key)
270 logging.info('Room ' + room_key + ' has state ' + str(room))
271 if other_user and other_user != user:
272 channel.send_message(make_client_id(room, other_user), '{"type":"bye"}')
273 logging.info('Sent BYE to ' + other_user)
274 logging.warning('User ' + user + ' disconnected from room ' + room_key)
275
276
277class MessagePage(webapp2.RequestHandler):
278 def post(self):
279 message = self.request.body
280 room_key = self.request.get('r')
281 user = self.request.get('u')
282 with LOCK:
283 room = Room.get_by_key_name(room_key)
284 if room:
285 handle_message(room, user, message)
286 else:
287 logging.warning('Unknown room ' + room_key)
288
289class MainPage(webapp2.RequestHandler):
290 """The main UI page, renders the 'index.html' template."""
291
292 def get(self):
293 """Renders the main page. When this page is shown, we create a new
294 channel to push asynchronous updates to the client."""
295 # get the base url without arguments.
296 base_url = self.request.path_url
297 room_key = sanitize(self.request.get('r'))
298 debug = self.request.get('debug')
299 unittest = self.request.get('unittest')
300 stun_server = self.request.get('ss')
301 turn_server = self.request.get('ts')
302 hd_video = self.request.get('hd')
303 ts_pwd = self.request.get('tp')
304 # set compat to true by default.
305 compat = 'true'
306 if self.request.get('compat'):
307 compat = self.request.get('compat')
308 if debug == 'loopback':
309 # set compat to false as DTLS does not work for loopback.
310 compat = 'false'
311
312
313 # token_timeout for channel creation, default 30min, max 2 days, min 3min.
314 token_timeout = self.request.get_range('tt',
315 min_value = 3,
316 max_value = 3000,
317 default = 30)
318
319 if unittest:
320 # Always create a new room for the unit tests.
321 room_key = generate_random(8)
322
323 if not room_key:
324 room_key = generate_random(8)
325 redirect = '/?r=' + room_key
326 redirect = append_url_arguments(self.request, redirect)
327 self.redirect(redirect)
328 logging.info('Redirecting visitor to base URL to ' + redirect)
329 return
330
331 user = None
332 initiator = 0
333 with LOCK:
334 room = Room.get_by_key_name(room_key)
335 if not room and debug != "full":
336 # New room.
337 user = generate_random(8)
338 room = Room(key_name = room_key)
339 room.add_user(user)
340 if debug != 'loopback':
341 initiator = 0
342 else:
343 room.add_user(user)
344 initiator = 1
345 elif room and room.get_occupancy() == 1 and debug != 'full':
346 # 1 occupant.
347 user = generate_random(8)
348 room.add_user(user)
349 initiator = 1
350 else:
351 # 2 occupants (full).
352 template = jinja_environment.get_template('full.html')
353 self.response.out.write(template.render({ 'room_key': room_key }))
354 logging.info('Room ' + room_key + ' is full')
355 return
356
357 room_link = base_url + '/?r=' + room_key
358 room_link = append_url_arguments(self.request, room_link)
359 token = create_channel(room, user, token_timeout)
360 pc_config = make_pc_config(stun_server, turn_server, ts_pwd)
361 pc_constraints = make_pc_constraints(compat)
362 offer_constraints = make_offer_constraints(compat)
363 media_constraints = make_media_constraints(hd_video)
364 template_values = {'token': token,
365 'me': user,
366 'room_key': room_key,
367 'room_link': room_link,
368 'initiator': initiator,
369 'pc_config': json.dumps(pc_config),
370 'pc_constraints': json.dumps(pc_constraints),
371 'offer_constraints': json.dumps(offer_constraints),
372 'media_constraints': json.dumps(media_constraints)
373 }
374 if unittest:
375 target_page = 'test/test_' + unittest + '.html'
376 else:
377 target_page = 'index.html'
378
379 template = jinja_environment.get_template(target_page)
380 self.response.out.write(template.render(template_values))
381 logging.info('User ' + user + ' added to room ' + room_key)
382 logging.info('Room ' + room_key + ' has state ' + str(room))
383
384
385app = webapp2.WSGIApplication([
386 ('/', MainPage),
387 ('/message', MessagePage),
388 ('/_ah/channel/connected/', ConnectPage),
389 ('/_ah/channel/disconnected/', DisconnectPage)
390 ], debug=True)