1 /* 2 * libjingle 3 * Copyright 2013, Google Inc. 4 * 5 * Redistribution and use in source and binary forms, with or without 6 * modification, are permitted provided that the following conditions are met: 7 * 8 * 1. Redistributions of source code must retain the above copyright notice, 9 * this list of conditions and the following disclaimer. 10 * 2. Redistributions in binary form must reproduce the above copyright notice, 11 * this list of conditions and the following disclaimer in the documentation 12 * and/or other materials provided with the distribution. 13 * 3. The name of the author may not be used to endorse or promote products 14 * derived from this software without specific prior written permission. 15 * 16 * THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR IMPLIED 17 * WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 18 * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO 19 * EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 20 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, 21 * PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; 22 * OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 23 * WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR 24 * OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF 25 * ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 */ 27 28 package org.appspot.apprtc; 29 30 import android.app.Activity; 31 import android.app.AlertDialog; 32 import android.content.DialogInterface; 33 import android.content.Intent; 34 import android.content.res.Configuration; 35 import android.graphics.Color; 36 import android.graphics.Point; 37 import android.media.AudioManager; 38 import android.os.Bundle; 39 import android.util.Log; 40 import android.util.TypedValue; 41 import android.view.View; 42 import android.view.ViewGroup.LayoutParams; 43 import android.view.WindowManager; 44 import android.webkit.JavascriptInterface; 45 import android.widget.EditText; 46 import android.widget.TextView; 47 import android.widget.Toast; 48 49 import org.json.JSONException; 50 import org.json.JSONObject; 51 import org.webrtc.DataChannel; 52 import org.webrtc.IceCandidate; 53 import org.webrtc.MediaConstraints; 54 import org.webrtc.MediaStream; 55 import org.webrtc.PeerConnection; 56 import org.webrtc.PeerConnectionFactory; 57 import org.webrtc.SdpObserver; 58 import org.webrtc.SessionDescription; 59 import org.webrtc.StatsObserver; 60 import org.webrtc.StatsReport; 61 import org.webrtc.VideoCapturer; 62 import org.webrtc.VideoRenderer; 63 import org.webrtc.VideoRendererGui; 64 import org.webrtc.VideoSource; 65 import org.webrtc.VideoTrack; 66 67 import java.util.LinkedList; 68 import java.util.List; 69 import java.util.regex.Matcher; 70 import java.util.regex.Pattern; 71 72 /** 73 * Main Activity of the AppRTCDemo Android app demonstrating interoperability 74 * between the Android/Java implementation of PeerConnection and the 75 * apprtc.appspot.com demo webapp. 76 */ 77 public class AppRTCDemoActivity extends Activity 78 implements AppRTCClient.IceServersObserver { 79 private static final String TAG = "AppRTCDemoActivity"; 80 private static boolean factoryStaticInitialized; 81 private PeerConnectionFactory factory; 82 private VideoSource videoSource; 83 private boolean videoSourceStopped; 84 private PeerConnection pc; 85 private final PCObserver pcObserver = new PCObserver(); 86 private final SDPObserver sdpObserver = new SDPObserver(); 87 private final GAEChannelClient.MessageHandler gaeHandler = new GAEHandler(); 88 private AppRTCClient appRtcClient = new AppRTCClient(this, gaeHandler, this); 89 private AppRTCGLView vsv; 90 private VideoRenderer.Callbacks localRender; 91 private VideoRenderer.Callbacks remoteRender; 92 private Toast logToast; 93 private final LayoutParams hudLayout = 94 new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); 95 private TextView hudView; 96 private LinkedList<IceCandidate> queuedRemoteCandidates = 97 new LinkedList<IceCandidate>(); 98 // Synchronize on quit[0] to avoid teardown-related crashes. 99 private final Boolean[] quit = new Boolean[] { false }; 100 private MediaConstraints sdpMediaConstraints; 101 102 @Override 103 public void onCreate(Bundle savedInstanceState) { 104 super.onCreate(savedInstanceState); 105 106 Thread.setDefaultUncaughtExceptionHandler( 107 new UnhandledExceptionHandler(this)); 108 109 getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); 110 getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); 111 112 Point displaySize = new Point(); 113 getWindowManager().getDefaultDisplay().getRealSize(displaySize); 114 115 vsv = new AppRTCGLView(this, displaySize); 116 VideoRendererGui.setView(vsv); 117 remoteRender = VideoRendererGui.create(0, 0, 100, 100); 118 localRender = VideoRendererGui.create(70, 5, 25, 25); 119 120 vsv.setOnClickListener(new View.OnClickListener() { 121 @Override public void onClick(View v) { 122 toggleHUD(); 123 } 124 }); 125 setContentView(vsv); 126 logAndToast("Tap the screen to toggle stats visibility"); 127 128 hudView = new TextView(this); 129 hudView.setTextColor(Color.BLACK); 130 hudView.setBackgroundColor(Color.WHITE); 131 hudView.setAlpha(0.4f); 132 hudView.setTextSize(TypedValue.COMPLEX_UNIT_PT, 5); 133 hudView.setVisibility(View.INVISIBLE); 134 addContentView(hudView, hudLayout); 135 136 if (!factoryStaticInitialized) { 137 abortUnless(PeerConnectionFactory.initializeAndroidGlobals( 138 this, true, true), 139 "Failed to initializeAndroidGlobals"); 140 factoryStaticInitialized = true; 141 } 142 143 AudioManager audioManager = 144 ((AudioManager) getSystemService(AUDIO_SERVICE)); 145 // TODO(fischman): figure out how to do this Right(tm) and remove the 146 // suppression. 147 @SuppressWarnings("deprecation") 148 boolean isWiredHeadsetOn = audioManager.isWiredHeadsetOn(); 149 audioManager.setMode(isWiredHeadsetOn ? 150 AudioManager.MODE_IN_CALL : AudioManager.MODE_IN_COMMUNICATION); 151 audioManager.setSpeakerphoneOn(!isWiredHeadsetOn); 152 153 sdpMediaConstraints = new MediaConstraints(); 154 sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair( 155 "OfferToReceiveAudio", "true")); 156 sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair( 157 "OfferToReceiveVideo", "true")); 158 159 final Intent intent = getIntent(); 160 if ("android.intent.action.VIEW".equals(intent.getAction())) { 161 connectToRoom(intent.getData().toString()); 162 return; 163 } 164 showGetRoomUI(); 165 } 166 167 private void showGetRoomUI() { 168 final EditText roomInput = new EditText(this); 169 roomInput.setText("https://apprtc.appspot.com/?r="); 170 roomInput.setSelection(roomInput.getText().length()); 171 DialogInterface.OnClickListener listener = 172 new DialogInterface.OnClickListener() { 173 @Override public void onClick(DialogInterface dialog, int which) { 174 abortUnless(which == DialogInterface.BUTTON_POSITIVE, "lolwat?"); 175 dialog.dismiss(); 176 connectToRoom(roomInput.getText().toString()); 177 } 178 }; 179 AlertDialog.Builder builder = new AlertDialog.Builder(this); 180 builder 181 .setMessage("Enter room URL").setView(roomInput) 182 .setPositiveButton("Go!", listener).show(); 183 } 184 185 private void connectToRoom(String roomUrl) { 186 logAndToast("Connecting to room..."); 187 appRtcClient.connectToRoom(roomUrl); 188 } 189 190 // Toggle visibility of the heads-up display. 191 private void toggleHUD() { 192 if (hudView.getVisibility() == View.VISIBLE) { 193 hudView.setVisibility(View.INVISIBLE); 194 } else { 195 hudView.setVisibility(View.VISIBLE); 196 } 197 } 198 199 // Update the heads-up display with information from |reports|. 200 private void updateHUD(StatsReport[] reports) { 201 StringBuilder builder = new StringBuilder(); 202 for (StatsReport report : reports) { 203 if (!report.id.equals("bweforvideo")) { 204 continue; 205 } 206 for (StatsReport.Value value : report.values) { 207 String name = value.name.replace("goog", "").replace("Available", "") 208 .replace("Bandwidth", "").replace("Bitrate", "").replace("Enc", ""); 209 builder.append(name).append("=").append(value.value).append(" "); 210 } 211 builder.append("\n"); 212 } 213 hudView.setText(builder.toString() + hudView.getText()); 214 } 215 216 @Override 217 public void onPause() { 218 super.onPause(); 219 vsv.onPause(); 220 if (videoSource != null) { 221 videoSource.stop(); 222 videoSourceStopped = true; 223 } 224 } 225 226 @Override 227 public void onResume() { 228 super.onResume(); 229 vsv.onResume(); 230 if (videoSource != null && videoSourceStopped) { 231 videoSource.restart(); 232 } 233 } 234 235 @Override 236 public void onConfigurationChanged (Configuration newConfig) { 237 Point displaySize = new Point(); 238 getWindowManager().getDefaultDisplay().getSize(displaySize); 239 vsv.updateDisplaySize(displaySize); 240 super.onConfigurationChanged(newConfig); 241 } 242 243 // Just for fun (and to regression-test bug 2302) make sure that DataChannels 244 // can be created, queried, and disposed. 245 private static void createDataChannelToRegressionTestBug2302( 246 PeerConnection pc) { 247 DataChannel dc = pc.createDataChannel("dcLabel", new DataChannel.Init()); 248 abortUnless("dcLabel".equals(dc.label()), "Unexpected label corruption?"); 249 dc.close(); 250 dc.dispose(); 251 } 252 253 @Override 254 public void onIceServers(List<PeerConnection.IceServer> iceServers) { 255 factory = new PeerConnectionFactory(); 256 257 MediaConstraints pcConstraints = appRtcClient.pcConstraints(); 258 pcConstraints.optional.add( 259 new MediaConstraints.KeyValuePair("RtpDataChannels", "true")); 260 pc = factory.createPeerConnection(iceServers, pcConstraints, pcObserver); 261 262 createDataChannelToRegressionTestBug2302(pc); // See method comment. 263 264 // Uncomment to get ALL WebRTC tracing and SENSITIVE libjingle logging. 265 // NOTE: this _must_ happen while |factory| is alive! 266 // Logging.enableTracing( 267 // "logcat:", 268 // EnumSet.of(Logging.TraceLevel.TRACE_ALL), 269 // Logging.Severity.LS_SENSITIVE); 270 271 { 272 final PeerConnection finalPC = pc; 273 final Runnable repeatedStatsLogger = new Runnable() { 274 public void run() { 275 synchronized (quit[0]) { 276 if (quit[0]) { 277 return; 278 } 279 final Runnable runnableThis = this; 280 if (hudView.getVisibility() == View.INVISIBLE) { 281 vsv.postDelayed(runnableThis, 1000); 282 return; 283 } 284 boolean success = finalPC.getStats(new StatsObserver() { 285 public void onComplete(final StatsReport[] reports) { 286 runOnUiThread(new Runnable() { 287 public void run() { 288 updateHUD(reports); 289 } 290 }); 291 for (StatsReport report : reports) { 292 Log.d(TAG, "Stats: " + report.toString()); 293 } 294 vsv.postDelayed(runnableThis, 1000); 295 } 296 }, null); 297 if (!success) { 298 throw new RuntimeException("getStats() return false!"); 299 } 300 } 301 } 302 }; 303 vsv.postDelayed(repeatedStatsLogger, 1000); 304 } 305 306 { 307 logAndToast("Creating local video source..."); 308 MediaStream lMS = factory.createLocalMediaStream("ARDAMS"); 309 if (appRtcClient.videoConstraints() != null) { 310 VideoCapturer capturer = getVideoCapturer(); 311 videoSource = factory.createVideoSource( 312 capturer, appRtcClient.videoConstraints()); 313 VideoTrack videoTrack = 314 factory.createVideoTrack("ARDAMSv0", videoSource); 315 videoTrack.addRenderer(new VideoRenderer(localRender)); 316 lMS.addTrack(videoTrack); 317 } 318 if (appRtcClient.audioConstraints() != null) { 319 lMS.addTrack(factory.createAudioTrack( 320 "ARDAMSa0", 321 factory.createAudioSource(appRtcClient.audioConstraints()))); 322 } 323 pc.addStream(lMS, new MediaConstraints()); 324 } 325 logAndToast("Waiting for ICE candidates..."); 326 } 327 328 // Cycle through likely device names for the camera and return the first 329 // capturer that works, or crash if none do. 330 private VideoCapturer getVideoCapturer() { 331 String[] cameraFacing = { "front", "back" }; 332 int[] cameraIndex = { 0, 1 }; 333 int[] cameraOrientation = { 0, 90, 180, 270 }; 334 for (String facing : cameraFacing) { 335 for (int index : cameraIndex) { 336 for (int orientation : cameraOrientation) { 337 String name = "Camera " + index + ", Facing " + facing + 338 ", Orientation " + orientation; 339 VideoCapturer capturer = VideoCapturer.create(name); 340 if (capturer != null) { 341 logAndToast("Using camera: " + name); 342 return capturer; 343 } 344 } 345 } 346 } 347 throw new RuntimeException("Failed to open capturer"); 348 } 349 350 @Override 351 protected void onDestroy() { 352 disconnectAndExit(); 353 super.onDestroy(); 354 } 355 356 // Poor-man's assert(): die with |msg| unless |condition| is true. 357 private static void abortUnless(boolean condition, String msg) { 358 if (!condition) { 359 throw new RuntimeException(msg); 360 } 361 } 362 363 // Log |msg| and Toast about it. 364 private void logAndToast(String msg) { 365 Log.d(TAG, msg); 366 if (logToast != null) { 367 logToast.cancel(); 368 } 369 logToast = Toast.makeText(this, msg, Toast.LENGTH_SHORT); 370 logToast.show(); 371 } 372 373 // Send |json| to the underlying AppEngine Channel. 374 private void sendMessage(JSONObject json) { 375 appRtcClient.sendMessage(json.toString()); 376 } 377 378 // Put a |key|->|value| mapping in |json|. 379 private static void jsonPut(JSONObject json, String key, Object value) { 380 try { 381 json.put(key, value); 382 } catch (JSONException e) { 383 throw new RuntimeException(e); 384 } 385 } 386 387 // Mangle SDP to prefer ISAC/16000 over any other audio codec. 388 private static String preferISAC(String sdpDescription) { 389 String[] lines = sdpDescription.split("\r\n"); 390 int mLineIndex = -1; 391 String isac16kRtpMap = null; 392 Pattern isac16kPattern = 393 Pattern.compile("^a=rtpmap:(\\d+) ISAC/16000[\r]?$"); 394 for (int i = 0; 395 (i < lines.length) && (mLineIndex == -1 || isac16kRtpMap == null); 396 ++i) { 397 if (lines[i].startsWith("m=audio ")) { 398 mLineIndex = i; 399 continue; 400 } 401 Matcher isac16kMatcher = isac16kPattern.matcher(lines[i]); 402 if (isac16kMatcher.matches()) { 403 isac16kRtpMap = isac16kMatcher.group(1); 404 continue; 405 } 406 } 407 if (mLineIndex == -1) { 408 Log.d(TAG, "No m=audio line, so can't prefer iSAC"); 409 return sdpDescription; 410 } 411 if (isac16kRtpMap == null) { 412 Log.d(TAG, "No ISAC/16000 line, so can't prefer iSAC"); 413 return sdpDescription; 414 } 415 String[] origMLineParts = lines[mLineIndex].split(" "); 416 StringBuilder newMLine = new StringBuilder(); 417 int origPartIndex = 0; 418 // Format is: m=<media> <port> <proto> <fmt> ... 419 newMLine.append(origMLineParts[origPartIndex++]).append(" "); 420 newMLine.append(origMLineParts[origPartIndex++]).append(" "); 421 newMLine.append(origMLineParts[origPartIndex++]).append(" "); 422 newMLine.append(isac16kRtpMap); 423 for (; origPartIndex < origMLineParts.length; ++origPartIndex) { 424 if (!origMLineParts[origPartIndex].equals(isac16kRtpMap)) { 425 newMLine.append(" ").append(origMLineParts[origPartIndex]); 426 } 427 } 428 lines[mLineIndex] = newMLine.toString(); 429 StringBuilder newSdpDescription = new StringBuilder(); 430 for (String line : lines) { 431 newSdpDescription.append(line).append("\r\n"); 432 } 433 return newSdpDescription.toString(); 434 } 435 436 // Implementation detail: observe ICE & stream changes and react accordingly. 437 private class PCObserver implements PeerConnection.Observer { 438 @Override public void onIceCandidate(final IceCandidate candidate){ 439 runOnUiThread(new Runnable() { 440 public void run() { 441 JSONObject json = new JSONObject(); 442 jsonPut(json, "type", "candidate"); 443 jsonPut(json, "label", candidate.sdpMLineIndex); 444 jsonPut(json, "id", candidate.sdpMid); 445 jsonPut(json, "candidate", candidate.sdp); 446 sendMessage(json); 447 } 448 }); 449 } 450 451 @Override public void onError(){ 452 runOnUiThread(new Runnable() { 453 public void run() { 454 throw new RuntimeException("PeerConnection error!"); 455 } 456 }); 457 } 458 459 @Override public void onSignalingChange( 460 PeerConnection.SignalingState newState) { 461 } 462 463 @Override public void onIceConnectionChange( 464 PeerConnection.IceConnectionState newState) { 465 } 466 467 @Override public void onIceGatheringChange( 468 PeerConnection.IceGatheringState newState) { 469 } 470 471 @Override public void onAddStream(final MediaStream stream){ 472 runOnUiThread(new Runnable() { 473 public void run() { 474 abortUnless(stream.audioTracks.size() <= 1 && 475 stream.videoTracks.size() <= 1, 476 "Weird-looking stream: " + stream); 477 if (stream.videoTracks.size() == 1) { 478 stream.videoTracks.get(0).addRenderer( 479 new VideoRenderer(remoteRender)); 480 } 481 } 482 }); 483 } 484 485 @Override public void onRemoveStream(final MediaStream stream){ 486 runOnUiThread(new Runnable() { 487 public void run() { 488 stream.videoTracks.get(0).dispose(); 489 } 490 }); 491 } 492 493 @Override public void onDataChannel(final DataChannel dc) { 494 runOnUiThread(new Runnable() { 495 public void run() { 496 throw new RuntimeException( 497 "AppRTC doesn't use data channels, but got: " + dc.label() + 498 " anyway!"); 499 } 500 }); 501 } 502 503 @Override public void onRenegotiationNeeded() { 504 // No need to do anything; AppRTC follows a pre-agreed-upon 505 // signaling/negotiation protocol. 506 } 507 } 508 509 // Implementation detail: handle offer creation/signaling and answer setting, 510 // as well as adding remote ICE candidates once the answer SDP is set. 511 private class SDPObserver implements SdpObserver { 512 private SessionDescription localSdp; 513 514 @Override public void onCreateSuccess(final SessionDescription origSdp) { 515 abortUnless(localSdp == null, "multiple SDP create?!?"); 516 final SessionDescription sdp = new SessionDescription( 517 origSdp.type, preferISAC(origSdp.description)); 518 localSdp = sdp; 519 runOnUiThread(new Runnable() { 520 public void run() { 521 pc.setLocalDescription(sdpObserver, sdp); 522 } 523 }); 524 } 525 526 // Helper for sending local SDP (offer or answer, depending on role) to the 527 // other participant. Note that it is important to send the output of 528 // create{Offer,Answer} and not merely the current value of 529 // getLocalDescription() because the latter may include ICE candidates that 530 // we might want to filter elsewhere. 531 private void sendLocalDescription() { 532 logAndToast("Sending " + localSdp.type); 533 JSONObject json = new JSONObject(); 534 jsonPut(json, "type", localSdp.type.canonicalForm()); 535 jsonPut(json, "sdp", localSdp.description); 536 sendMessage(json); 537 } 538 539 @Override public void onSetSuccess() { 540 runOnUiThread(new Runnable() { 541 public void run() { 542 if (appRtcClient.isInitiator()) { 543 if (pc.getRemoteDescription() != null) { 544 // We've set our local offer and received & set the remote 545 // answer, so drain candidates. 546 drainRemoteCandidates(); 547 } else { 548 // We've just set our local description so time to send it. 549 sendLocalDescription(); 550 } 551 } else { 552 if (pc.getLocalDescription() == null) { 553 // We just set the remote offer, time to create our answer. 554 logAndToast("Creating answer"); 555 pc.createAnswer(SDPObserver.this, sdpMediaConstraints); 556 } else { 557 // Answer now set as local description; send it and drain 558 // candidates. 559 sendLocalDescription(); 560 drainRemoteCandidates(); 561 } 562 } 563 } 564 }); 565 } 566 567 @Override public void onCreateFailure(final String error) { 568 runOnUiThread(new Runnable() { 569 public void run() { 570 throw new RuntimeException("createSDP error: " + error); 571 } 572 }); 573 } 574 575 @Override public void onSetFailure(final String error) { 576 runOnUiThread(new Runnable() { 577 public void run() { 578 throw new RuntimeException("setSDP error: " + error); 579 } 580 }); 581 } 582 583 private void drainRemoteCandidates() { 584 for (IceCandidate candidate : queuedRemoteCandidates) { 585 pc.addIceCandidate(candidate); 586 } 587 queuedRemoteCandidates = null; 588 } 589 } 590 591 // Implementation detail: handler for receiving GAE messages and dispatching 592 // them appropriately. 593 private class GAEHandler implements GAEChannelClient.MessageHandler { 594 @JavascriptInterface public void onOpen() { 595 if (!appRtcClient.isInitiator()) { 596 return; 597 } 598 logAndToast("Creating offer..."); 599 pc.createOffer(sdpObserver, sdpMediaConstraints); 600 } 601 602 @JavascriptInterface public void onMessage(String data) { 603 try { 604 JSONObject json = new JSONObject(data); 605 String type = (String) json.get("type"); 606 if (type.equals("candidate")) { 607 IceCandidate candidate = new IceCandidate( 608 (String) json.get("id"), 609 json.getInt("label"), 610 (String) json.get("candidate")); 611 if (queuedRemoteCandidates != null) { 612 queuedRemoteCandidates.add(candidate); 613 } else { 614 pc.addIceCandidate(candidate); 615 } 616 } else if (type.equals("answer") || type.equals("offer")) { 617 SessionDescription sdp = new SessionDescription( 618 SessionDescription.Type.fromCanonicalForm(type), 619 preferISAC((String) json.get("sdp"))); 620 pc.setRemoteDescription(sdpObserver, sdp); 621 } else if (type.equals("bye")) { 622 logAndToast("Remote end hung up; dropping PeerConnection"); 623 disconnectAndExit(); 624 } else { 625 throw new RuntimeException("Unexpected message: " + data); 626 } 627 } catch (JSONException e) { 628 throw new RuntimeException(e); 629 } 630 } 631 632 @JavascriptInterface public void onClose() { 633 disconnectAndExit(); 634 } 635 636 @JavascriptInterface public void onError(int code, String description) { 637 disconnectAndExit(); 638 } 639 } 640 641 // Disconnect from remote resources, dispose of local resources, and exit. 642 private void disconnectAndExit() { 643 synchronized (quit[0]) { 644 if (quit[0]) { 645 return; 646 } 647 quit[0] = true; 648 if (pc != null) { 649 pc.dispose(); 650 pc = null; 651 } 652 if (appRtcClient != null) { 653 appRtcClient.sendMessage("{\"type\": \"bye\"}"); 654 appRtcClient.disconnect(); 655 appRtcClient = null; 656 } 657 if (videoSource != null) { 658 videoSource.dispose(); 659 videoSource = null; 660 } 661 if (factory != null) { 662 factory.dispose(); 663 factory = null; 664 } 665 finish(); 666 } 667 } 668 669 } 670