1 /* 2 * Copyright 2018 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 package android.media; 18 19 import static android.media.MediaConstants.KEY_ALLOWED_COMMANDS; 20 import static android.media.MediaConstants.KEY_CONNECTION_HINTS; 21 import static android.media.MediaConstants.KEY_PACKAGE_NAME; 22 import static android.media.MediaConstants.KEY_PID; 23 import static android.media.MediaConstants.KEY_PLAYBACK_ACTIVE; 24 import static android.media.MediaConstants.KEY_SESSION2LINK; 25 import static android.media.MediaConstants.KEY_TOKEN_EXTRAS; 26 import static android.media.Session2Command.Result.RESULT_ERROR_UNKNOWN_ERROR; 27 import static android.media.Session2Command.Result.RESULT_INFO_SKIPPED; 28 import static android.media.Session2Token.TYPE_SESSION; 29 30 import android.annotation.NonNull; 31 import android.annotation.Nullable; 32 import android.content.ComponentName; 33 import android.content.Context; 34 import android.content.Intent; 35 import android.content.ServiceConnection; 36 import android.os.Bundle; 37 import android.os.Handler; 38 import android.os.IBinder; 39 import android.os.Process; 40 import android.os.RemoteException; 41 import android.os.ResultReceiver; 42 import android.util.ArrayMap; 43 import android.util.ArraySet; 44 import android.util.Log; 45 46 import java.util.concurrent.Executor; 47 48 /** 49 * This API is not generally intended for third party application developers. 50 * Use the <a href="{@docRoot}jetpack/androidx.html">AndroidX</a> 51 * <a href="{@docRoot}reference/androidx/media2/session/package-summary.html">Media2 session 52 * Library</a> for consistent behavior across all devices. 53 * 54 * Allows an app to interact with an active {@link MediaSession2} or a 55 * {@link MediaSession2Service} which would provide {@link MediaSession2}. Media buttons and other 56 * commands can be sent to the session. 57 */ 58 public class MediaController2 implements AutoCloseable { 59 static final String TAG = "MediaController2"; 60 static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG); 61 62 @SuppressWarnings("WeakerAccess") /* synthetic access */ 63 final ControllerCallback mCallback; 64 65 private final IBinder.DeathRecipient mDeathRecipient = () -> close(); 66 private final Context mContext; 67 private final Session2Token mSessionToken; 68 private final Executor mCallbackExecutor; 69 private final Controller2Link mControllerStub; 70 private final Handler mResultHandler; 71 private final SessionServiceConnection mServiceConnection; 72 73 private final Object mLock = new Object(); 74 //@GuardedBy("mLock") 75 private boolean mClosed; 76 //@GuardedBy("mLock") 77 private int mNextSeqNumber; 78 //@GuardedBy("mLock") 79 private Session2Link mSessionBinder; 80 //@GuardedBy("mLock") 81 private Session2CommandGroup mAllowedCommands; 82 //@GuardedBy("mLock") 83 private Session2Token mConnectedToken; 84 //@GuardedBy("mLock") 85 private ArrayMap<ResultReceiver, Integer> mPendingCommands; 86 //@GuardedBy("mLock") 87 private ArraySet<Integer> mRequestedCommandSeqNumbers; 88 //@GuardedBy("mLock") 89 private boolean mPlaybackActive; 90 91 /** 92 * Create a {@link MediaController2} from the {@link Session2Token}. 93 * This connects to the session and may wake up the service if it's not available. 94 * 95 * @param context context 96 * @param token token to connect to 97 * @param connectionHints a session-specific argument to send to the session when connecting. 98 * The contents of this bundle may affect the connection result. 99 * @param executor executor to run callbacks on. 100 * @param callback controller callback to receive changes in. 101 */ MediaController2(@onNull Context context, @NonNull Session2Token token, @NonNull Bundle connectionHints, @NonNull Executor executor, @NonNull ControllerCallback callback)102 MediaController2(@NonNull Context context, @NonNull Session2Token token, 103 @NonNull Bundle connectionHints, @NonNull Executor executor, 104 @NonNull ControllerCallback callback) { 105 if (context == null) { 106 throw new IllegalArgumentException("context shouldn't be null"); 107 } 108 if (token == null) { 109 throw new IllegalArgumentException("token shouldn't be null"); 110 } 111 mContext = context; 112 mSessionToken = token; 113 mCallbackExecutor = (executor == null) ? context.getMainExecutor() : executor; 114 mCallback = (callback == null) ? new ControllerCallback() {} : callback; 115 mControllerStub = new Controller2Link(this); 116 // NOTE: mResultHandler uses main looper, so this MUST NOT be blocked. 117 mResultHandler = new Handler(context.getMainLooper()); 118 119 mNextSeqNumber = 0; 120 mPendingCommands = new ArrayMap<>(); 121 mRequestedCommandSeqNumbers = new ArraySet<>(); 122 123 boolean connectRequested; 124 if (token.getType() == TYPE_SESSION) { 125 mServiceConnection = null; 126 connectRequested = requestConnectToSession(connectionHints); 127 } else { 128 mServiceConnection = new SessionServiceConnection(connectionHints); 129 connectRequested = requestConnectToService(); 130 } 131 if (!connectRequested) { 132 close(); 133 } 134 } 135 136 @Override close()137 public void close() { 138 synchronized (mLock) { 139 if (mClosed) { 140 // Already closed. Ignore rest of clean up code. 141 // Note: unbindService() throws IllegalArgumentException when it's called twice. 142 return; 143 } 144 if (DEBUG) { 145 Log.d(TAG, "closing " + this); 146 } 147 mClosed = true; 148 if (mServiceConnection != null) { 149 // Note: This should be called even when the bindService() has returned false. 150 mContext.unbindService(mServiceConnection); 151 } 152 if (mSessionBinder != null) { 153 try { 154 mSessionBinder.disconnect(mControllerStub, getNextSeqNumber()); 155 mSessionBinder.unlinkToDeath(mDeathRecipient, 0); 156 } catch (RuntimeException e) { 157 // No-op 158 } 159 } 160 mConnectedToken = null; 161 mPendingCommands.clear(); 162 mRequestedCommandSeqNumbers.clear(); 163 mCallbackExecutor.execute(() -> { 164 mCallback.onDisconnected(MediaController2.this); 165 }); 166 mSessionBinder = null; 167 } 168 } 169 170 /** 171 * Returns {@link Session2Token} of the connected session. 172 * If it is not connected yet, it returns {@code null}. 173 * <p> 174 * This may differ with the {@link Session2Token} from the constructor. For example, if the 175 * controller is created with the token for {@link MediaSession2Service}, this would return 176 * token for the {@link MediaSession2} in the service. 177 * 178 * @return Session2Token of the connected session, or {@code null} if not connected 179 */ 180 @Nullable getConnectedToken()181 public Session2Token getConnectedToken() { 182 synchronized (mLock) { 183 return mConnectedToken; 184 } 185 } 186 187 /** 188 * Returns whether the session's playback is active. 189 * 190 * @return {@code true} if playback active. {@code false} otherwise. 191 * @see ControllerCallback#onPlaybackActiveChanged(MediaController2, boolean) 192 */ isPlaybackActive()193 public boolean isPlaybackActive() { 194 synchronized (mLock) { 195 return mPlaybackActive; 196 } 197 } 198 199 /** 200 * Sends a session command to the session 201 * <p> 202 * @param command the session command 203 * @param args optional arguments 204 * @return a token which will be sent together in {@link ControllerCallback#onCommandResult} 205 * when its result is received. 206 */ 207 @NonNull sendSessionCommand(@onNull Session2Command command, @Nullable Bundle args)208 public Object sendSessionCommand(@NonNull Session2Command command, @Nullable Bundle args) { 209 if (command == null) { 210 throw new IllegalArgumentException("command shouldn't be null"); 211 } 212 213 ResultReceiver resultReceiver = new ResultReceiver(mResultHandler) { 214 protected void onReceiveResult(int resultCode, Bundle resultData) { 215 synchronized (mLock) { 216 mPendingCommands.remove(this); 217 } 218 mCallbackExecutor.execute(() -> { 219 mCallback.onCommandResult(MediaController2.this, this, 220 command, new Session2Command.Result(resultCode, resultData)); 221 }); 222 } 223 }; 224 225 synchronized (mLock) { 226 if (mSessionBinder != null) { 227 int seq = getNextSeqNumber(); 228 mPendingCommands.put(resultReceiver, seq); 229 try { 230 mSessionBinder.sendSessionCommand(mControllerStub, seq, command, args, 231 resultReceiver); 232 } catch (RuntimeException e) { 233 mPendingCommands.remove(resultReceiver); 234 resultReceiver.send(RESULT_ERROR_UNKNOWN_ERROR, null); 235 } 236 } 237 } 238 return resultReceiver; 239 } 240 241 /** 242 * Cancels the session command previously sent. 243 * 244 * @param token the token which is returned from {@link #sendSessionCommand}. 245 */ cancelSessionCommand(@onNull Object token)246 public void cancelSessionCommand(@NonNull Object token) { 247 if (token == null) { 248 throw new IllegalArgumentException("token shouldn't be null"); 249 } 250 synchronized (mLock) { 251 if (mSessionBinder == null) return; 252 Integer seq = mPendingCommands.remove(token); 253 if (seq != null) { 254 mSessionBinder.cancelSessionCommand(mControllerStub, seq); 255 } 256 } 257 } 258 259 // Called by Controller2Link.onConnected onConnected(int seq, Bundle connectionResult)260 void onConnected(int seq, Bundle connectionResult) { 261 Session2Link sessionBinder = connectionResult.getParcelable(KEY_SESSION2LINK); 262 Session2CommandGroup allowedCommands = 263 connectionResult.getParcelable(KEY_ALLOWED_COMMANDS); 264 boolean playbackActive = connectionResult.getBoolean(KEY_PLAYBACK_ACTIVE); 265 266 Bundle tokenExtras = connectionResult.getBundle(KEY_TOKEN_EXTRAS); 267 if (tokenExtras == null) { 268 Log.w(TAG, "extras shouldn't be null."); 269 tokenExtras = Bundle.EMPTY; 270 } else if (MediaSession2.hasCustomParcelable(tokenExtras)) { 271 Log.w(TAG, "extras contain custom parcelable. Ignoring."); 272 tokenExtras = Bundle.EMPTY; 273 } 274 275 if (DEBUG) { 276 Log.d(TAG, "notifyConnected sessionBinder=" + sessionBinder 277 + ", allowedCommands=" + allowedCommands); 278 } 279 if (sessionBinder == null || allowedCommands == null) { 280 // Connection rejected. 281 close(); 282 return; 283 } 284 synchronized (mLock) { 285 mSessionBinder = sessionBinder; 286 mAllowedCommands = allowedCommands; 287 mPlaybackActive = playbackActive; 288 289 // Implementation for the local binder is no-op, 290 // so can be used without worrying about deadlock. 291 sessionBinder.linkToDeath(mDeathRecipient, 0); 292 mConnectedToken = new Session2Token(mSessionToken.getUid(), TYPE_SESSION, 293 mSessionToken.getPackageName(), sessionBinder, tokenExtras); 294 } 295 mCallbackExecutor.execute(() -> { 296 mCallback.onConnected(MediaController2.this, allowedCommands); 297 }); 298 } 299 300 // Called by Controller2Link.onDisconnected onDisconnected(int seq)301 void onDisconnected(int seq) { 302 // close() will call mCallback.onDisconnected 303 close(); 304 } 305 306 // Called by Controller2Link.onPlaybackActiveChanged onPlaybackActiveChanged(int seq, boolean playbackActive)307 void onPlaybackActiveChanged(int seq, boolean playbackActive) { 308 synchronized (mLock) { 309 mPlaybackActive = playbackActive; 310 } 311 mCallbackExecutor.execute(() -> { 312 mCallback.onPlaybackActiveChanged(MediaController2.this, playbackActive); 313 }); 314 } 315 316 // Called by Controller2Link.onSessionCommand onSessionCommand(int seq, Session2Command command, Bundle args, @Nullable ResultReceiver resultReceiver)317 void onSessionCommand(int seq, Session2Command command, Bundle args, 318 @Nullable ResultReceiver resultReceiver) { 319 synchronized (mLock) { 320 mRequestedCommandSeqNumbers.add(seq); 321 } 322 mCallbackExecutor.execute(() -> { 323 boolean isCanceled; 324 synchronized (mLock) { 325 isCanceled = !mRequestedCommandSeqNumbers.remove(seq); 326 } 327 if (isCanceled) { 328 if (resultReceiver != null) { 329 resultReceiver.send(RESULT_INFO_SKIPPED, null); 330 } 331 return; 332 } 333 Session2Command.Result result = mCallback.onSessionCommand( 334 MediaController2.this, command, args); 335 if (resultReceiver != null) { 336 if (result == null) { 337 resultReceiver.send(RESULT_INFO_SKIPPED, null); 338 } else { 339 resultReceiver.send(result.getResultCode(), result.getResultData()); 340 } 341 } 342 }); 343 } 344 345 // Called by Controller2Link.onSessionCommand onCancelCommand(int seq)346 void onCancelCommand(int seq) { 347 synchronized (mLock) { 348 mRequestedCommandSeqNumbers.remove(seq); 349 } 350 } 351 getNextSeqNumber()352 private int getNextSeqNumber() { 353 synchronized (mLock) { 354 return mNextSeqNumber++; 355 } 356 } 357 createConnectionRequest(@onNull Bundle connectionHints)358 private Bundle createConnectionRequest(@NonNull Bundle connectionHints) { 359 Bundle connectionRequest = new Bundle(); 360 connectionRequest.putString(KEY_PACKAGE_NAME, mContext.getPackageName()); 361 connectionRequest.putInt(KEY_PID, Process.myPid()); 362 connectionRequest.putBundle(KEY_CONNECTION_HINTS, connectionHints); 363 return connectionRequest; 364 } 365 requestConnectToSession(@onNull Bundle connectionHints)366 private boolean requestConnectToSession(@NonNull Bundle connectionHints) { 367 Session2Link sessionBinder = mSessionToken.getSessionLink(); 368 Bundle connectionRequest = createConnectionRequest(connectionHints); 369 try { 370 sessionBinder.connect(mControllerStub, getNextSeqNumber(), connectionRequest); 371 } catch (RuntimeException e) { 372 Log.w(TAG, "Failed to call connection request", e); 373 return false; 374 } 375 return true; 376 } 377 requestConnectToService()378 private boolean requestConnectToService() { 379 // Service. Needs to get fresh binder whenever connection is needed. 380 final Intent intent = new Intent(MediaSession2Service.SERVICE_INTERFACE); 381 intent.setClassName(mSessionToken.getPackageName(), mSessionToken.getServiceName()); 382 383 // Use bindService() instead of startForegroundService() to start session service for three 384 // reasons. 385 // 1. Prevent session service owner's stopSelf() from destroying service. 386 // With the startForegroundService(), service's call of stopSelf() will trigger immediate 387 // onDestroy() calls on the main thread even when onConnect() is running in another 388 // thread. 389 // 2. Minimize APIs for developers to take care about. 390 // With bindService(), developers only need to take care about Service.onBind() 391 // but Service.onStartCommand() should be also taken care about with the 392 // startForegroundService(). 393 // 3. Future support for UI-less playback 394 // If a service wants to keep running, it should be either foreground service or 395 // bound service. But there had been request for the feature for system apps 396 // and using bindService() will be better fit with it. 397 synchronized (mLock) { 398 boolean result = mContext.bindService( 399 intent, mServiceConnection, Context.BIND_AUTO_CREATE); 400 if (!result) { 401 Log.w(TAG, "bind to " + mSessionToken + " failed"); 402 return false; 403 } else if (DEBUG) { 404 Log.d(TAG, "bind to " + mSessionToken + " succeeded"); 405 } 406 } 407 return true; 408 } 409 410 /** 411 * This API is not generally intended for third party application developers. 412 * Use the <a href="{@docRoot}jetpack/androidx.html">AndroidX</a> 413 * <a href="{@docRoot}reference/androidx/media2/session/package-summary.html">Media2 session 414 * Library</a> for consistent behavior across all devices. 415 * <p> 416 * Builder for {@link MediaController2}. 417 * <p> 418 * Any incoming event from the {@link MediaSession2} will be handled on the callback 419 * executor. If it's not set, {@link Context#getMainExecutor()} will be used by default. 420 */ 421 public static final class Builder { 422 private Context mContext; 423 private Session2Token mToken; 424 private Bundle mConnectionHints; 425 private Executor mCallbackExecutor; 426 private ControllerCallback mCallback; 427 428 /** 429 * Creates a builder for {@link MediaController2}. 430 * 431 * @param context context 432 * @param token token of the session to connect to 433 */ Builder(@onNull Context context, @NonNull Session2Token token)434 public Builder(@NonNull Context context, @NonNull Session2Token token) { 435 if (context == null) { 436 throw new IllegalArgumentException("context shouldn't be null"); 437 } 438 if (token == null) { 439 throw new IllegalArgumentException("token shouldn't be null"); 440 } 441 mContext = context; 442 mToken = token; 443 } 444 445 /** 446 * Set the connection hints for the controller. 447 * <p> 448 * {@code connectionHints} is a session-specific argument to send to the session when 449 * connecting. The contents of this bundle may affect the connection result. 450 * <p> 451 * An {@link IllegalArgumentException} will be thrown if the bundle contains any 452 * non-framework Parcelable objects. 453 * 454 * @param connectionHints a bundle which contains the connection hints 455 * @return The Builder to allow chaining 456 */ 457 @NonNull setConnectionHints(@onNull Bundle connectionHints)458 public Builder setConnectionHints(@NonNull Bundle connectionHints) { 459 if (connectionHints == null) { 460 throw new IllegalArgumentException("connectionHints shouldn't be null"); 461 } 462 if (MediaSession2.hasCustomParcelable(connectionHints)) { 463 throw new IllegalArgumentException("connectionHints shouldn't contain any custom " 464 + "parcelables"); 465 } 466 mConnectionHints = new Bundle(connectionHints); 467 return this; 468 } 469 470 /** 471 * Set callback for the controller and its executor. 472 * 473 * @param executor callback executor 474 * @param callback session callback. 475 * @return The Builder to allow chaining 476 */ 477 @NonNull setControllerCallback(@onNull Executor executor, @NonNull ControllerCallback callback)478 public Builder setControllerCallback(@NonNull Executor executor, 479 @NonNull ControllerCallback callback) { 480 if (executor == null) { 481 throw new IllegalArgumentException("executor shouldn't be null"); 482 } 483 if (callback == null) { 484 throw new IllegalArgumentException("callback shouldn't be null"); 485 } 486 mCallbackExecutor = executor; 487 mCallback = callback; 488 return this; 489 } 490 491 /** 492 * Build {@link MediaController2}. 493 * 494 * @return a new controller 495 */ 496 @NonNull build()497 public MediaController2 build() { 498 if (mCallbackExecutor == null) { 499 mCallbackExecutor = mContext.getMainExecutor(); 500 } 501 if (mCallback == null) { 502 mCallback = new ControllerCallback() {}; 503 } 504 if (mConnectionHints == null) { 505 mConnectionHints = Bundle.EMPTY; 506 } 507 return new MediaController2( 508 mContext, mToken, mConnectionHints, mCallbackExecutor, mCallback); 509 } 510 } 511 512 /** 513 * This API is not generally intended for third party application developers. 514 * Use the <a href="{@docRoot}jetpack/androidx.html">AndroidX</a> 515 * <a href="{@docRoot}reference/androidx/media2/session/package-summary.html">Media2 session 516 * Library</a> for consistent behavior across all devices. 517 * <p> 518 * Interface for listening to change in activeness of the {@link MediaSession2}. 519 */ 520 public abstract static class ControllerCallback { 521 /** 522 * Called when the controller is successfully connected to the session. The controller 523 * becomes available afterwards. 524 * 525 * @param controller the controller for this event 526 * @param allowedCommands commands that's allowed by the session. 527 */ onConnected(@onNull MediaController2 controller, @NonNull Session2CommandGroup allowedCommands)528 public void onConnected(@NonNull MediaController2 controller, 529 @NonNull Session2CommandGroup allowedCommands) {} 530 531 /** 532 * Called when the session refuses the controller or the controller is disconnected from 533 * the session. The controller becomes unavailable afterwards and the callback wouldn't 534 * be called. 535 * <p> 536 * It will be also called after the {@link #close()}, so you can put clean up code here. 537 * You don't need to call {@link #close()} after this. 538 * 539 * @param controller the controller for this event 540 */ onDisconnected(@onNull MediaController2 controller)541 public void onDisconnected(@NonNull MediaController2 controller) {} 542 543 /** 544 * Called when the session's playback activeness is changed. 545 * 546 * @param controller the controller for this event 547 * @param playbackActive {@code true} if the session's playback is active. 548 * {@code false} otherwise. 549 * @see MediaController2#isPlaybackActive() 550 */ onPlaybackActiveChanged(@onNull MediaController2 controller, boolean playbackActive)551 public void onPlaybackActiveChanged(@NonNull MediaController2 controller, 552 boolean playbackActive) {} 553 554 /** 555 * Called when the connected session sent a session command. 556 * 557 * @param controller the controller for this event 558 * @param command the session command 559 * @param args optional arguments 560 * @return the result for the session command. If {@code null}, RESULT_INFO_SKIPPED 561 * will be sent to the session. 562 */ 563 @Nullable onSessionCommand(@onNull MediaController2 controller, @NonNull Session2Command command, @Nullable Bundle args)564 public Session2Command.Result onSessionCommand(@NonNull MediaController2 controller, 565 @NonNull Session2Command command, @Nullable Bundle args) { 566 return null; 567 } 568 569 /** 570 * Called when the command sent to the connected session is finished. 571 * 572 * @param controller the controller for this event 573 * @param token the token got from {@link MediaController2#sendSessionCommand} 574 * @param command the session command 575 * @param result the result of the session command 576 */ onCommandResult(@onNull MediaController2 controller, @NonNull Object token, @NonNull Session2Command command, @NonNull Session2Command.Result result)577 public void onCommandResult(@NonNull MediaController2 controller, @NonNull Object token, 578 @NonNull Session2Command command, @NonNull Session2Command.Result result) {} 579 } 580 581 // This will be called on the main thread. 582 private class SessionServiceConnection implements ServiceConnection { 583 private final Bundle mConnectionHints; 584 SessionServiceConnection(@ullable Bundle connectionHints)585 SessionServiceConnection(@Nullable Bundle connectionHints) { 586 mConnectionHints = connectionHints; 587 } 588 589 @Override onServiceConnected(ComponentName name, IBinder service)590 public void onServiceConnected(ComponentName name, IBinder service) { 591 // Note that it's always main-thread. 592 boolean connectRequested = false; 593 try { 594 if (DEBUG) { 595 Log.d(TAG, "onServiceConnected " + name + " " + this); 596 } 597 // Sanity check 598 if (!mSessionToken.getPackageName().equals(name.getPackageName())) { 599 Log.wtf(TAG, "Expected connection to " + mSessionToken.getPackageName() 600 + " but is connected to " + name); 601 return; 602 } 603 IMediaSession2Service iService = IMediaSession2Service.Stub.asInterface(service); 604 if (iService == null) { 605 Log.wtf(TAG, "Service interface is missing."); 606 return; 607 } 608 Bundle connectionRequest = createConnectionRequest(mConnectionHints); 609 iService.connect(mControllerStub, getNextSeqNumber(), connectionRequest); 610 connectRequested = true; 611 } catch (RemoteException e) { 612 Log.w(TAG, "Service " + name + " has died prematurely", e); 613 } finally { 614 if (!connectRequested) { 615 close(); 616 } 617 } 618 } 619 620 @Override onServiceDisconnected(ComponentName name)621 public void onServiceDisconnected(ComponentName name) { 622 // Temporal lose of the binding because of the service crash. System will automatically 623 // rebind, so just no-op. 624 if (DEBUG) { 625 Log.w(TAG, "Session service " + name + " is disconnected."); 626 } 627 close(); 628 } 629 630 @Override onBindingDied(ComponentName name)631 public void onBindingDied(ComponentName name) { 632 // Permanent lose of the binding because of the service package update or removed. 633 // This SessionServiceRecord will be removed accordingly, but forget session binder here 634 // for sure. 635 close(); 636 } 637 } 638 } 639