Chromium Code Reviews
chromiumcodereview-hr@appspot.gserviceaccount.com (chromiumcodereview-hr) | Please choose your nickname with Settings | Help | Chromium Project | Gerrit Changes | Sign out
(121)

Side by Side Diff: webrtc/examples/androidapp/src/org/appspot/apprtc/AppRTCBluetoothManager.java

Issue 2501983002: Adds basic Bluetooth support to AppRTCMobile (Closed)
Patch Set: Final comments from magjed@ Created 4 years ago
Use n/p to move between diff chunks; N/P to move between comments. Draft comments are only viewable by you.
Jump to:
View unified diff | Download patch
OLDNEW
(Empty)
1 /*
2 * Copyright 2016 The WebRTC Project Authors. All rights reserved.
3 *
4 * Use of this source code is governed by a BSD-style license
5 * that can be found in the LICENSE file in the root of the source
6 * tree. An additional intellectual property rights grant can be found
7 * in the file PATENTS. All contributing project authors may
8 * be found in the AUTHORS file in the root of the source tree.
9 */
10
11 package org.appspot.apprtc;
12
13 import org.appspot.apprtc.util.AppRTCUtils;
14
15 import android.bluetooth.BluetoothAdapter;
16 import android.bluetooth.BluetoothDevice;
17 import android.bluetooth.BluetoothHeadset;
18 import android.bluetooth.BluetoothProfile;
19 import android.content.BroadcastReceiver;
20 import android.content.Context;
21 import android.content.Intent;
22 import android.content.IntentFilter;
23 import android.content.pm.PackageManager;
24 import android.media.AudioManager;
25 import android.os.Handler;
26 import android.os.Looper;
27 import android.os.Process;
28 import android.util.Log;
29
30 import org.webrtc.ThreadUtils;
31
32 import java.util.List;
33 import java.util.Set;
34
35 /**
36 * AppRTCProximitySensor manages functions related to Bluetoth devices in the
37 * AppRTC demo.
38 */
39 public class AppRTCBluetoothManager {
40 private static final String TAG = "AppRTCBluetoothManager";
41
42 // Timeout interval for starting or stopping audio to a Bluetooth SCO device.
43 private static final int BLUETOOTH_SCO_TIMEOUT_MS = 4000;
44 // Maximum number of SCO connection attempts.
45 private static final int MAX_SCO_CONNECTION_ATTEMPTS = 2;
46
47 // Bluetooth connection state.
48 public enum State {
49 // Bluetooth is not available; no adapter or Bluetooth is off.
50 UNINITIALIZED,
51 // Bluetooth error happened when trying to start Bluetooth.
52 ERROR,
53 // Bluetooth proxy object for the Headset profile exists, but no connected h eadset devices,
54 // SCO is not started or disconnected.
55 HEADSET_UNAVAILABLE,
56 // Bluetooth proxy object for the Headset profile connected, connected Bluet ooth headset
57 // present, but SCO is not started or disconnected.
58 HEADSET_AVAILABLE,
59 // Bluetooth audio SCO connection with remote device is closing.
60 SCO_DISCONNECTING,
61 // Bluetooth audio SCO connection with remote device is initiated.
62 SCO_CONNECTING,
63 // Bluetooth audio SCO connection with remote device is established.
64 SCO_CONNECTED
65 }
66
67 private final Context apprtcContext;
68 private final AppRTCAudioManager apprtcAudioManager;
69 private final AudioManager audioManager;
70 private final Handler handler;
71
72 int scoConnectionAttempts;
73 private State bluetoothState;
74 private final BluetoothProfile.ServiceListener bluetoothServiceListener;
75 private BluetoothAdapter bluetoothAdapter;
76 private BluetoothHeadset bluetoothHeadset;
77 private BluetoothDevice bluetoothDevice;
78 private final BroadcastReceiver bluetoothHeadsetReceiver;
79
80 // Runs when the Bluetooth timeout expires. We use that timeout after calling
81 // startScoAudio() or stopScoAudio() because we're not guaranteed to get a
82 // callback after those calls.
83 private final Runnable bluetoothTimeoutRunnable = new Runnable() {
84 @Override
85 public void run() {
86 bluetoothTimeout();
87 }
88 };
89
90 /**
91 * Implementation of an interface that notifies BluetoothProfile IPC clients w hen they have been
92 * connected to or disconnected from the service.
93 */
94 private class BluetoothServiceListener implements BluetoothProfile.ServiceList ener {
95 @Override
96 // Called to notify the client when the proxy object has been connected to t he service.
97 // Once we have the profile proxy object, we can use it to monitor the state of the
98 // connection and perform other operations that are relevant to the headset profile.
99 public void onServiceConnected(int profile, BluetoothProfile proxy) {
100 if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITI ALIZED) {
101 return;
102 }
103 Log.d(TAG, "BluetoothServiceListener.onServiceConnected: BT state=" + blue toothState);
104 // Android only supports one connected Bluetooth Headset at a time.
105 bluetoothHeadset = (BluetoothHeadset) proxy;
106 updateAudioDeviceState();
107 Log.d(TAG, "onServiceConnected done: BT state=" + bluetoothState);
108 }
109
110 @Override
111 /** Notifies the client when the proxy object has been disconnected from the service. */
112 public void onServiceDisconnected(int profile) {
113 if (profile != BluetoothProfile.HEADSET || bluetoothState == State.UNINITI ALIZED) {
114 return;
115 }
116 Log.d(TAG, "BluetoothServiceListener.onServiceDisconnected: BT state=" + b luetoothState);
117 stopScoAudio();
118 bluetoothHeadset = null;
119 bluetoothDevice = null;
120 bluetoothState = State.HEADSET_UNAVAILABLE;
121 updateAudioDeviceState();
122 Log.d(TAG, "onServiceDisconnected done: BT state=" + bluetoothState);
123 }
124 }
125
126 // Intent broadcast receiver which handles changes in Bluetooth device availab ility.
127 // Detects headset changes and Bluetooth SCO state changes.
128 private class BluetoothHeadsetBroadcastReceiver extends BroadcastReceiver {
129 @Override
130 public void onReceive(Context context, Intent intent) {
131 if (bluetoothState == State.UNINITIALIZED) {
132 return;
133 }
134 final String action = intent.getAction();
135 // Change in connection state of the Headset profile. Note that the
136 // change does not tell us anything about whether we're streaming
137 // audio to BT over SCO. Typically received when user turns on a BT
138 // headset while audio is active using another audio device.
139 if (action.equals(BluetoothHeadset.ACTION_CONNECTION_STATE_CHANGED)) {
140 final int state =
141 intent.getIntExtra(BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.ST ATE_DISCONNECTED);
142 Log.d(TAG, "BluetoothHeadsetBroadcastReceiver.onReceive: "
143 + "a=ACTION_CONNECTION_STATE_CHANGED, "
144 + "s=" + stateToString(state) + ", "
145 + "sb=" + isInitialStickyBroadcast() + ", "
146 + "BT state: " + bluetoothState);
147 if (state == BluetoothHeadset.STATE_CONNECTED) {
148 scoConnectionAttempts = 0;
149 updateAudioDeviceState();
150 } else if (state == BluetoothHeadset.STATE_CONNECTING) {
151 // No action needed.
152 } else if (state == BluetoothHeadset.STATE_DISCONNECTING) {
153 // No action needed.
154 } else if (state == BluetoothHeadset.STATE_DISCONNECTED) {
155 // Bluetooth is probably powered off during the call.
156 stopScoAudio();
157 updateAudioDeviceState();
158 }
159 // Change in the audio (SCO) connection state of the Headset profile.
160 // Typically received after call to startScoAudio() has finalized.
161 } else if (action.equals(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED)) {
162 final int state = intent.getIntExtra(
163 BluetoothHeadset.EXTRA_STATE, BluetoothHeadset.STATE_AUDIO_DISCONNEC TED);
164 Log.d(TAG, "BluetoothHeadsetBroadcastReceiver.onReceive: "
165 + "a=ACTION_AUDIO_STATE_CHANGED, "
166 + "s=" + stateToString(state) + ", "
167 + "sb=" + isInitialStickyBroadcast() + ", "
168 + "BT state: " + bluetoothState);
169 if (state == BluetoothHeadset.STATE_AUDIO_CONNECTED) {
170 cancelTimer();
171 if (bluetoothState == State.SCO_CONNECTING) {
172 Log.d(TAG, "+++ Bluetooth audio SCO is now connected");
173 bluetoothState = State.SCO_CONNECTED;
174 scoConnectionAttempts = 0;
175 updateAudioDeviceState();
176 } else {
177 Log.w(TAG, "Unexpected state BluetoothHeadset.STATE_AUDIO_CONNECTED" );
178 }
179 } else if (state == BluetoothHeadset.STATE_AUDIO_CONNECTING) {
180 Log.d(TAG, "+++ Bluetooth audio SCO is now connecting...");
181 } else if (state == BluetoothHeadset.STATE_AUDIO_DISCONNECTED) {
182 Log.d(TAG, "+++ Bluetooth audio SCO is now disconnected");
183 if (isInitialStickyBroadcast()) {
184 Log.d(TAG, "Ignore STATE_AUDIO_DISCONNECTED initial sticky broadcast .");
185 return;
186 }
187 updateAudioDeviceState();
188 }
189 }
190 Log.d(TAG, "onReceive done: BT state=" + bluetoothState);
191 }
192 };
193
194 /** Construction. */
195 static AppRTCBluetoothManager create(Context context, AppRTCAudioManager audio Manager) {
196 Log.d(TAG, "create" + AppRTCUtils.getThreadInfo());
197 return new AppRTCBluetoothManager(context, audioManager);
198 }
199
200 protected AppRTCBluetoothManager(Context context, AppRTCAudioManager audioMana ger) {
201 Log.d(TAG, "ctor");
202 ThreadUtils.checkIsOnMainThread();
203 apprtcContext = context;
204 apprtcAudioManager = audioManager;
205 this.audioManager = getAudioManager(context);
206 bluetoothState = State.UNINITIALIZED;
207 bluetoothServiceListener = new BluetoothServiceListener();
208 bluetoothHeadsetReceiver = new BluetoothHeadsetBroadcastReceiver();
209 handler = new Handler(Looper.getMainLooper());
210 }
211
212 /** Returns the internal state. */
213 public State getState() {
214 ThreadUtils.checkIsOnMainThread();
215 return bluetoothState;
216 }
217
218 /**
219 * Activates components required to detect Bluetooth devices and to enable
220 * BT SCO (audio is routed via BT SCO) for the headset profile. The end
221 * state will be HEADSET_UNAVAILABLE but a state machine has started which
222 * will start a state change sequence where the final outcome depends on
223 * if/when the BT headset is enabled.
224 * Example of state change sequence when start() is called while BT device
225 * is connected and enabled:
226 * UNINITIALIZED --> HEADSET_UNAVAILABLE --> HEADSET_AVAILABLE -->
227 * SCO_CONNECTING --> SCO_CONNECTED <==> audio is now routed via BT SCO.
228 * Note that the AppRTCAudioManager is also involved in driving this state
229 * change.
230 */
231 public void start() {
232 ThreadUtils.checkIsOnMainThread();
233 Log.d(TAG, "start");
234 if (!hasPermission(apprtcContext, android.Manifest.permission.BLUETOOTH)) {
235 Log.w(TAG, "Process (pid=" + Process.myPid() + ") lacks BLUETOOTH permissi on");
236 return;
237 }
238 if (bluetoothState != State.UNINITIALIZED) {
239 Log.w(TAG, "Invalid BT state");
240 return;
241 }
242 bluetoothHeadset = null;
243 bluetoothDevice = null;
244 scoConnectionAttempts = 0;
245 // Get a handle to the default local Bluetooth adapter.
246 bluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
247 if (bluetoothAdapter == null) {
248 Log.w(TAG, "Device does not support Bluetooth");
249 return;
250 }
251 // Ensure that the device supports use of BT SCO audio for off call use case s.
252 if (!audioManager.isBluetoothScoAvailableOffCall()) {
253 Log.e(TAG, "Bluetooth SCO audio is not available off call");
254 return;
255 }
256 logBluetoothAdapterInfo(bluetoothAdapter);
257 // Establish a connection to the HEADSET profile (includes both Bluetooth He adset and
258 // Hands-Free) proxy object and install a listener.
259 if (!getBluetoothProfileProxy(
260 apprtcContext, bluetoothServiceListener, BluetoothProfile.HEADSET)) {
261 Log.e(TAG, "BluetoothAdapter.getProfileProxy(HEADSET) failed");
262 return;
263 }
264 // Register receivers for BluetoothHeadset change notifications.
265 IntentFilter bluetoothHeadsetFilter = new IntentFilter();
266 // Register receiver for change in connection state of the Headset profile.
267 bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_CONNECTION_STATE_CH ANGED);
268 // Register receiver for change in audio connection state of the Headset pro file.
269 bluetoothHeadsetFilter.addAction(BluetoothHeadset.ACTION_AUDIO_STATE_CHANGED );
270 registerReceiver(bluetoothHeadsetReceiver, bluetoothHeadsetFilter);
271 Log.d(TAG, "HEADSET profile state: "
272 + stateToString(bluetoothAdapter.getProfileConnectionState(Bluetooth Profile.HEADSET)));
273 Log.d(TAG, "Bluetooth proxy for headset profile has started");
274 bluetoothState = State.HEADSET_UNAVAILABLE;
275 Log.d(TAG, "start done: BT state=" + bluetoothState);
276 }
277
278 /** Stops and closes all components related to Bluetooth audio. */
279 public void stop() {
280 ThreadUtils.checkIsOnMainThread();
281 unregisterReceiver(bluetoothHeadsetReceiver);
282 Log.d(TAG, "stop: BT state=" + bluetoothState);
283 if (bluetoothAdapter != null) {
284 // Stop BT SCO connection with remote device if needed.
285 stopScoAudio();
286 // Close down remaining BT resources.
287 if (bluetoothState != State.UNINITIALIZED) {
288 cancelTimer();
289 if (bluetoothHeadset != null) {
290 bluetoothAdapter.closeProfileProxy(BluetoothProfile.HEADSET, bluetooth Headset);
291 bluetoothHeadset = null;
292 }
293 bluetoothAdapter = null;
294 bluetoothDevice = null;
295 bluetoothState = State.UNINITIALIZED;
296 }
297 }
298 Log.d(TAG, "stop done: BT state=" + bluetoothState);
299 }
300
301 /**
302 * Starts Bluetooth SCO connection with remote device.
303 * Note that the phone application always has the priority on the usage of the SCO connection
304 * for telephony. If this method is called while the phone is in call it will be ignored.
305 * Similarly, if a call is received or sent while an application is using the SCO connection,
306 * the connection will be lost for the application and NOT returned automatica lly when the call
307 * ends. Also note that: up to and including API version JELLY_BEAN_MR1, this method initiates a
308 * virtual voice call to the Bluetooth headset. After API version JELLY_BEAN_M R2 only a raw SCO
309 * audio connection is established.
310 * TODO(henrika): should we add support for virtual voice call to BT headset a lso for JBMR2 and
311 * higher. It might be required to initiates a virtual voice call since many d evices do not
312 * accept SCO audio without a "call".
313 */
314 public boolean startScoAudio() {
315 ThreadUtils.checkIsOnMainThread();
316 Log.d(TAG, "startSco: BT state=" + bluetoothState + ", "
317 + "attempts: " + scoConnectionAttempts + ", "
318 + "SCO is on: " + isScoOn());
319 if (scoConnectionAttempts >= MAX_SCO_CONNECTION_ATTEMPTS) {
320 Log.e(TAG, "BT SCO connection fails - no more attempts");
321 return false;
322 }
323 if (bluetoothState != State.HEADSET_AVAILABLE) {
324 Log.e(TAG, "BT SCO connection fails - no headset available");
325 return false;
326 }
327 // Start BT SCO channel and wait for ACTION_AUDIO_STATE_CHANGED.
328 Log.d(TAG, "Starting Bluetooth SCO and waits for ACTION_AUDIO_STATE_CHANGED. ..");
329 // The SCO connection establishment can take several seconds, hence we canno t rely on the
330 // connection to be available when the method returns but instead register t o receive the
331 // intent ACTION_SCO_AUDIO_STATE_UPDATED and wait for the state to be SCO_AU DIO_STATE_CONNECTED.
332 bluetoothState = State.SCO_CONNECTING;
333 audioManager.startBluetoothSco();
334 scoConnectionAttempts++;
335 startTimer();
336 Log.d(TAG, "startScoAudio done: BT state=" + bluetoothState);
337 return true;
338 }
339
340 /** Stops Bluetooth SCO connection with remote device. */
341 public void stopScoAudio() {
342 ThreadUtils.checkIsOnMainThread();
343 Log.d(TAG, "stopScoAudio: BT state=" + bluetoothState + ", "
344 + "SCO is on: " + isScoOn());
345 if (bluetoothState != State.SCO_CONNECTING && bluetoothState != State.SCO_CO NNECTED) {
346 return;
347 }
348 cancelTimer();
349 audioManager.stopBluetoothSco();
350 bluetoothState = State.SCO_DISCONNECTING;
351 Log.d(TAG, "stopScoAudio done: BT state=" + bluetoothState);
352 }
353
354 /**
355 * Use the BluetoothHeadset proxy object (controls the Bluetooth Headset
356 * Service via IPC) to update the list of connected devices for the HEADSET
357 * profile. The internal state will change to HEADSET_UNAVAILABLE or to
358 * HEADSET_AVAILABLE and |bluetoothDevice| will be mapped to the connected
359 * device if available.
360 */
361 public void updateDevice() {
362 if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
363 return;
364 }
365 Log.d(TAG, "updateDevice");
366 // Get connected devices for the headset profile. Returns the set of
367 // devices which are in state STATE_CONNECTED. The BluetoothDevice class
368 // is just a thin wrapper for a Bluetooth hardware address.
369 List<BluetoothDevice> devices = bluetoothHeadset.getConnectedDevices();
370 if (devices.isEmpty()) {
371 bluetoothDevice = null;
372 bluetoothState = State.HEADSET_UNAVAILABLE;
373 Log.d(TAG, "No connected bluetooth headset");
374 } else {
375 // Always use first device is list. Android only supports one device.
376 bluetoothDevice = devices.get(0);
377 bluetoothState = State.HEADSET_AVAILABLE;
378 Log.d(TAG, "Connected bluetooth headset: "
379 + "name=" + bluetoothDevice.getName() + ", "
380 + "state=" + stateToString(bluetoothHeadset.getConnectionState(blu etoothDevice))
381 + ", SCO audio=" + bluetoothHeadset.isAudioConnected(bluetoothDevi ce));
382 }
383 Log.d(TAG, "updateDevice done: BT state=" + bluetoothState);
384 }
385
386 /**
387 * Stubs for test mocks.
388 */
389 protected AudioManager getAudioManager(Context context) {
390 return (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
391 }
392
393 protected void registerReceiver(BroadcastReceiver receiver, IntentFilter filte r) {
394 apprtcContext.registerReceiver(receiver, filter);
395 }
396
397 protected void unregisterReceiver(BroadcastReceiver receiver) {
398 apprtcContext.unregisterReceiver(receiver);
399 }
400
401 protected boolean getBluetoothProfileProxy(
402 Context context, BluetoothProfile.ServiceListener listener, int profile) {
403 return bluetoothAdapter.getProfileProxy(context, listener, profile);
404 }
405
406 protected boolean hasPermission(Context context, String permission) {
407 return apprtcContext.checkPermission(permission, Process.myPid(), Process.my Uid())
408 == PackageManager.PERMISSION_GRANTED;
409 }
410
411 /** Logs the state of the local Bluetooth adapter. */
412 protected void logBluetoothAdapterInfo(BluetoothAdapter localAdapter) {
413 Log.d(TAG, "BluetoothAdapter: "
414 + "enabled=" + localAdapter.isEnabled() + ", "
415 + "state=" + stateToString(localAdapter.getState()) + ", "
416 + "name=" + localAdapter.getName() + ", "
417 + "address=" + localAdapter.getAddress());
418 // Log the set of BluetoothDevice objects that are bonded (paired) to the lo cal adapter.
419 Set<BluetoothDevice> pairedDevices = localAdapter.getBondedDevices();
420 if (!pairedDevices.isEmpty()) {
421 Log.d(TAG, "paired devices:");
422 for (BluetoothDevice device : pairedDevices) {
423 Log.d(TAG, " name=" + device.getName() + ", address=" + device.getAddres s());
424 }
425 }
426 }
427
428 /** Ensures that the audio manager updates its list of available audio devices . */
429 private void updateAudioDeviceState() {
430 ThreadUtils.checkIsOnMainThread();
431 Log.d(TAG, "updateAudioDeviceState");
432 apprtcAudioManager.updateAudioDeviceState();
433 }
434
435 /** Starts timer which times out after BLUETOOTH_SCO_TIMEOUT_MS milliseconds. */
436 private void startTimer() {
437 ThreadUtils.checkIsOnMainThread();
438 Log.d(TAG, "startTimer");
439 handler.postDelayed(bluetoothTimeoutRunnable, BLUETOOTH_SCO_TIMEOUT_MS);
440 }
441
442 /** Cancels any outstanding timer tasks. */
443 private void cancelTimer() {
444 ThreadUtils.checkIsOnMainThread();
445 Log.d(TAG, "cancelTimer");
446 handler.removeCallbacks(bluetoothTimeoutRunnable);
447 }
448
449 /**
450 * Called when start of the BT SCO channel takes too long time. Usually
451 * happens when the BT device has been turned on during an ongoing call.
452 */
453 private void bluetoothTimeout() {
454 ThreadUtils.checkIsOnMainThread();
455 if (bluetoothState == State.UNINITIALIZED || bluetoothHeadset == null) {
456 return;
457 }
458 Log.d(TAG, "bluetoothTimeout: BT state=" + bluetoothState + ", "
459 + "attempts: " + scoConnectionAttempts + ", "
460 + "SCO is on: " + isScoOn());
461 if (bluetoothState != State.SCO_CONNECTING) {
462 return;
463 }
464 // Bluetooth SCO should be connecting; check the latest result.
465 boolean scoConnected = false;
466 List<BluetoothDevice> devices = bluetoothHeadset.getConnectedDevices();
467 if (devices.size() > 0) {
468 bluetoothDevice = devices.get(0);
469 if (bluetoothHeadset.isAudioConnected(bluetoothDevice)) {
470 Log.d(TAG, "SCO connected with " + bluetoothDevice.getName());
471 scoConnected = true;
472 } else {
473 Log.d(TAG, "SCO is not connected with " + bluetoothDevice.getName());
474 }
475 }
476 if (scoConnected) {
477 // We thought BT had timed out, but it's actually on; updating state.
478 bluetoothState = State.SCO_CONNECTED;
479 scoConnectionAttempts = 0;
480 } else {
481 // Give up and "cancel" our request by calling stopBluetoothSco().
482 Log.w(TAG, "BT failed to connect after timeout");
483 stopScoAudio();
484 }
485 updateAudioDeviceState();
486 Log.d(TAG, "bluetoothTimeout done: BT state=" + bluetoothState);
487 }
488
489 /** Checks whether audio uses Bluetooth SCO. */
490 private boolean isScoOn() {
491 return audioManager.isBluetoothScoOn();
492 }
493
494 /** Converts BluetoothAdapter states into local string representations. */
495 private String stateToString(int state) {
496 switch (state) {
497 case BluetoothAdapter.STATE_DISCONNECTED:
498 return "DISCONNECTED";
499 case BluetoothAdapter.STATE_CONNECTED:
500 return "CONNECTED";
501 case BluetoothAdapter.STATE_CONNECTING:
502 return "CONNECTING";
503 case BluetoothAdapter.STATE_DISCONNECTING:
504 return "DISCONNECTING";
505 case BluetoothAdapter.STATE_OFF:
506 return "OFF";
507 case BluetoothAdapter.STATE_ON:
508 return "ON";
509 case BluetoothAdapter.STATE_TURNING_OFF:
510 // Indicates the local Bluetooth adapter is turning off. Local clients s hould immediately
511 // attempt graceful disconnection of any remote links.
512 return "TURNING_OFF";
513 case BluetoothAdapter.STATE_TURNING_ON:
514 // Indicates the local Bluetooth adapter is turning on. However local cl ients should wait
515 // for STATE_ON before attempting to use the adapter.
516 return "TURNING_ON";
517 default:
518 return "INVALID";
519 }
520 }
521 }
OLDNEW

Powered by Google App Engine
This is Rietveld 408576698