1 /* 2 * Copyright (C) 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 com.android.systemui.volume; 18 19 import android.animation.Animator; 20 import android.animation.AnimatorInflater; 21 import android.animation.AnimatorSet; 22 import android.annotation.DrawableRes; 23 import android.annotation.Nullable; 24 import android.app.Dialog; 25 import android.app.KeyguardManager; 26 import android.car.Car; 27 import android.car.CarNotConnectedException; 28 import android.car.media.CarAudioManager; 29 import android.content.ComponentName; 30 import android.content.Context; 31 import android.content.DialogInterface; 32 import android.content.ServiceConnection; 33 import android.content.res.TypedArray; 34 import android.content.res.XmlResourceParser; 35 import android.graphics.Color; 36 import android.graphics.PixelFormat; 37 import android.graphics.drawable.ColorDrawable; 38 import android.graphics.drawable.Drawable; 39 import android.media.AudioManager; 40 import android.os.Debug; 41 import android.os.Handler; 42 import android.os.IBinder; 43 import android.os.Looper; 44 import android.os.Message; 45 import android.util.AttributeSet; 46 import android.util.Log; 47 import android.util.SparseArray; 48 import android.util.Xml; 49 import android.view.Gravity; 50 import android.view.MotionEvent; 51 import android.view.View; 52 import android.view.ViewGroup; 53 import android.view.Window; 54 import android.view.WindowManager; 55 import android.widget.SeekBar; 56 import android.widget.SeekBar.OnSeekBarChangeListener; 57 58 import androidx.recyclerview.widget.LinearLayoutManager; 59 import androidx.recyclerview.widget.RecyclerView; 60 61 import com.android.systemui.R; 62 import com.android.systemui.plugins.VolumeDialog; 63 64 import org.xmlpull.v1.XmlPullParserException; 65 66 import java.io.IOException; 67 import java.util.ArrayList; 68 import java.util.Iterator; 69 import java.util.List; 70 71 /** 72 * Car version of the volume dialog. 73 * 74 * Methods ending in "H" must be called on the (ui) handler. 75 */ 76 public class CarVolumeDialogImpl implements VolumeDialog { 77 78 private static final String TAG = Util.logTag(CarVolumeDialogImpl.class); 79 80 private static final String XML_TAG_VOLUME_ITEMS = "carVolumeItems"; 81 private static final String XML_TAG_VOLUME_ITEM = "item"; 82 private static final int HOVERING_TIMEOUT = 16000; 83 private static final int NORMAL_TIMEOUT = 3000; 84 private static final int LISTVIEW_ANIMATION_DURATION_IN_MILLIS = 250; 85 private static final int DISMISS_DELAY_IN_MILLIS = 50; 86 private static final int ARROW_FADE_IN_START_DELAY_IN_MILLIS = 100; 87 88 private final Context mContext; 89 private final H mHandler = new H(); 90 // All the volume items. 91 private final SparseArray<VolumeItem> mVolumeItems = new SparseArray<>(); 92 // Available volume items in car audio manager. 93 private final List<VolumeItem> mAvailableVolumeItems = new ArrayList<>(); 94 // Volume items in the RecyclerView. 95 private final List<CarVolumeItem> mCarVolumeLineItems = new ArrayList<>(); 96 private final KeyguardManager mKeyguard; 97 private Window mWindow; 98 private CustomDialog mDialog; 99 private RecyclerView mListView; 100 private CarVolumeItemAdapter mVolumeItemsAdapter; 101 private Car mCar; 102 private CarAudioManager mCarAudioManager; 103 private final CarAudioManager.CarVolumeCallback mVolumeChangeCallback = 104 new CarAudioManager.CarVolumeCallback() { 105 @Override 106 public void onGroupVolumeChanged(int zoneId, int groupId, int flags) { 107 // TODO: Include zoneId into consideration. 108 // For instance 109 // - single display + single-zone, ignore zoneId 110 // - multi-display + single-zone, zoneId is fixed, may show volume bar on all 111 // displays 112 // - single-display + multi-zone, may show volume bar on primary display only 113 // - multi-display + multi-zone, may show volume bar on display specified by 114 // zoneId 115 VolumeItem volumeItem = mAvailableVolumeItems.get(groupId); 116 int value = getSeekbarValue(mCarAudioManager, groupId); 117 // find if the group id for which the volume changed is currently being 118 // displayed. 119 boolean isShowing = mCarVolumeLineItems.stream().anyMatch( 120 item -> item.getGroupId() == groupId); 121 // Do not update the progress if it is the same as before. When car audio 122 // manager sets 123 // its group volume caused by the seekbar progress changed, it also triggers 124 // this 125 // callback. Updating the seekbar at the same time could block the continuous 126 // seeking. 127 if (value != volumeItem.progress && isShowing) { 128 volumeItem.carVolumeItem.setProgress(value); 129 volumeItem.progress = value; 130 } 131 if ((flags & AudioManager.FLAG_SHOW_UI) != 0) { 132 mCurrentlyDisplayingGroupId = groupId; 133 mHandler.obtainMessage(H.SHOW, 134 Events.SHOW_REASON_VOLUME_CHANGED).sendToTarget(); 135 } 136 } 137 138 @Override 139 public void onMasterMuteChanged(int zoneId, int flags) { 140 // ignored 141 } 142 }; 143 private boolean mHovering; 144 private int mCurrentlyDisplayingGroupId; 145 private boolean mShowing; 146 private boolean mExpanded; 147 private View mExpandIcon; 148 private final ServiceConnection mServiceConnection = new ServiceConnection() { 149 @Override 150 public void onServiceConnected(ComponentName name, IBinder service) { 151 try { 152 mExpanded = false; 153 mCarAudioManager = (CarAudioManager) mCar.getCarManager(Car.AUDIO_SERVICE); 154 int volumeGroupCount = mCarAudioManager.getVolumeGroupCount(); 155 // Populates volume slider items from volume groups to UI. 156 for (int groupId = 0; groupId < volumeGroupCount; groupId++) { 157 VolumeItem volumeItem = getVolumeItemForUsages( 158 mCarAudioManager.getUsagesForVolumeGroupId(groupId)); 159 mAvailableVolumeItems.add(volumeItem); 160 // The first one is the default item. 161 if (groupId == 0) { 162 setuptListItem(0); 163 } 164 } 165 166 // If list is already initiated, update its content. 167 if (mVolumeItemsAdapter != null) { 168 mVolumeItemsAdapter.notifyDataSetChanged(); 169 } 170 mCarAudioManager.registerCarVolumeCallback(mVolumeChangeCallback); 171 } catch (CarNotConnectedException e) { 172 Log.e(TAG, "Car is not connected!", e); 173 } 174 } 175 176 /** 177 * This does not get called when service is properly disconnected. 178 * So we need to also handle cleanups in destroy(). 179 */ 180 @Override 181 public void onServiceDisconnected(ComponentName name) { 182 cleanupAudioManager(); 183 } 184 }; 185 186 private void setuptListItem(int groupId) { 187 mCarVolumeLineItems.clear(); 188 VolumeItem volumeItem = mAvailableVolumeItems.get(groupId); 189 volumeItem.defaultItem = true; 190 addCarVolumeListItem(volumeItem, /* volumeGroupId = */ groupId, 191 R.drawable.car_ic_keyboard_arrow_down, new ExpandIconListener() 192 ); 193 } 194 195 public CarVolumeDialogImpl(Context context) { 196 mContext = context; 197 mKeyguard = (KeyguardManager) mContext.getSystemService(Context.KEYGUARD_SERVICE); 198 mCar = Car.createCar(mContext, mServiceConnection); 199 } 200 201 private static int getSeekbarValue(CarAudioManager carAudioManager, int volumeGroupId) { 202 try { 203 return carAudioManager.getGroupVolume(volumeGroupId); 204 } catch (CarNotConnectedException e) { 205 Log.e(TAG, "Car is not connected!", e); 206 } 207 return 0; 208 } 209 210 private static int getMaxSeekbarValue(CarAudioManager carAudioManager, int volumeGroupId) { 211 try { 212 return carAudioManager.getGroupMaxVolume(volumeGroupId); 213 } catch (CarNotConnectedException e) { 214 Log.e(TAG, "Car is not connected!", e); 215 } 216 return 0; 217 } 218 219 /** 220 * Build the volume window and connect to the CarService which registers with car audio 221 * manager. 222 */ 223 @Override 224 public void init(int windowType, Callback callback) { 225 initDialog(); 226 227 mCar.connect(); 228 } 229 230 @Override 231 public void destroy() { 232 mHandler.removeCallbacksAndMessages(null); 233 234 cleanupAudioManager(); 235 // unregisterVolumeCallback is not being called when disconnect car, so we manually cleanup 236 // audio manager beforehand. 237 mCar.disconnect(); 238 } 239 240 private void initDialog() { 241 loadAudioUsageItems(); 242 mCarVolumeLineItems.clear(); 243 mDialog = new CustomDialog(mContext); 244 245 mHovering = false; 246 mShowing = false; 247 mExpanded = false; 248 mWindow = mDialog.getWindow(); 249 mWindow.requestFeature(Window.FEATURE_NO_TITLE); 250 mWindow.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT)); 251 mWindow.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND 252 | WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR); 253 mWindow.addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE 254 | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN 255 | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL 256 | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED 257 | WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH 258 | WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED); 259 mWindow.setType(WindowManager.LayoutParams.TYPE_VOLUME_OVERLAY); 260 mWindow.setWindowAnimations(com.android.internal.R.style.Animation_Toast); 261 final WindowManager.LayoutParams lp = mWindow.getAttributes(); 262 lp.format = PixelFormat.TRANSLUCENT; 263 lp.setTitle(VolumeDialogImpl.class.getSimpleName()); 264 lp.gravity = Gravity.TOP | Gravity.CENTER_HORIZONTAL; 265 lp.windowAnimations = -1; 266 mWindow.setAttributes(lp); 267 268 mDialog.setContentView(R.layout.car_volume_dialog); 269 mWindow.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); 270 271 mDialog.setCanceledOnTouchOutside(true); 272 mDialog.setOnShowListener(dialog -> { 273 mListView.setTranslationY(-mListView.getHeight()); 274 mListView.setAlpha(0); 275 mListView.animate() 276 .alpha(1) 277 .translationY(0) 278 .setDuration(LISTVIEW_ANIMATION_DURATION_IN_MILLIS) 279 .setInterpolator(new SystemUIInterpolators.LogDecelerateInterpolator()) 280 .start(); 281 }); 282 mListView = mWindow.findViewById(R.id.volume_list); 283 mListView.setOnHoverListener((v, event) -> { 284 int action = event.getActionMasked(); 285 mHovering = (action == MotionEvent.ACTION_HOVER_ENTER) 286 || (action == MotionEvent.ACTION_HOVER_MOVE); 287 rescheduleTimeoutH(); 288 return true; 289 }); 290 291 mVolumeItemsAdapter = new CarVolumeItemAdapter(mContext, mCarVolumeLineItems); 292 mListView.setAdapter(mVolumeItemsAdapter); 293 mListView.setLayoutManager(new LinearLayoutManager(mContext)); 294 } 295 296 297 private void showH(int reason) { 298 if (D.BUG) { 299 Log.d(TAG, "showH r=" + Events.DISMISS_REASONS[reason]); 300 } 301 302 mHandler.removeMessages(H.SHOW); 303 mHandler.removeMessages(H.DISMISS); 304 rescheduleTimeoutH(); 305 // Refresh the data set before showing. 306 mVolumeItemsAdapter.notifyDataSetChanged(); 307 if (mShowing) { 308 return; 309 } 310 mShowing = true; 311 setuptListItem(mCurrentlyDisplayingGroupId); 312 mDialog.show(); 313 Events.writeEvent(mContext, Events.EVENT_SHOW_DIALOG, reason, mKeyguard.isKeyguardLocked()); 314 } 315 316 private void rescheduleTimeoutH() { 317 mHandler.removeMessages(H.DISMISS); 318 final int timeout = computeTimeoutH(); 319 mHandler.sendMessageDelayed(mHandler 320 .obtainMessage(H.DISMISS, Events.DISMISS_REASON_TIMEOUT), timeout); 321 322 if (D.BUG) { 323 Log.d(TAG, "rescheduleTimeout " + timeout + " " + Debug.getCaller()); 324 } 325 } 326 327 private int computeTimeoutH() { 328 return mHovering ? HOVERING_TIMEOUT : NORMAL_TIMEOUT; 329 } 330 331 private void dismissH(int reason) { 332 if (D.BUG) { 333 Log.d(TAG, "dismissH r=" + Events.DISMISS_REASONS[reason]); 334 } 335 336 mHandler.removeMessages(H.DISMISS); 337 mHandler.removeMessages(H.SHOW); 338 if (!mShowing) { 339 return; 340 } 341 342 mListView.animate().cancel(); 343 344 mListView.setTranslationY(0); 345 mListView.setAlpha(1); 346 mListView.animate() 347 .alpha(0) 348 .translationY(-mListView.getHeight()) 349 .setDuration(LISTVIEW_ANIMATION_DURATION_IN_MILLIS) 350 .setInterpolator(new SystemUIInterpolators.LogAccelerateInterpolator()) 351 .withEndAction(() -> mHandler.postDelayed(() -> { 352 if (D.BUG) { 353 Log.d(TAG, "mDialog.dismiss()"); 354 } 355 mDialog.dismiss(); 356 mShowing = false; 357 mShowing = false; 358 // if mExpandIcon is null that means user never clicked on the expanded arrow 359 // which implies that the dialog is still not expanded. In that case we do 360 // not want to reset the state 361 if (mExpandIcon != null && mExpanded) { 362 toggleDialogExpansion(/* isClicked = */ false); 363 } 364 }, DISMISS_DELAY_IN_MILLIS)) 365 .start(); 366 367 Events.writeEvent(mContext, Events.EVENT_DISMISS_DIALOG, reason); 368 } 369 370 private void loadAudioUsageItems() { 371 try (XmlResourceParser parser = mContext.getResources().getXml(R.xml.car_volume_items)) { 372 AttributeSet attrs = Xml.asAttributeSet(parser); 373 int type; 374 // Traverse to the first start tag 375 while ((type = parser.next()) != XmlResourceParser.END_DOCUMENT 376 && type != XmlResourceParser.START_TAG) { 377 // Do Nothing (moving parser to start element) 378 } 379 380 if (!XML_TAG_VOLUME_ITEMS.equals(parser.getName())) { 381 throw new RuntimeException("Meta-data does not start with carVolumeItems tag"); 382 } 383 int outerDepth = parser.getDepth(); 384 int rank = 0; 385 while ((type = parser.next()) != XmlResourceParser.END_DOCUMENT 386 && (type != XmlResourceParser.END_TAG || parser.getDepth() > outerDepth)) { 387 if (type == XmlResourceParser.END_TAG) { 388 continue; 389 } 390 if (XML_TAG_VOLUME_ITEM.equals(parser.getName())) { 391 TypedArray item = mContext.getResources().obtainAttributes( 392 attrs, R.styleable.carVolumeItems_item); 393 int usage = item.getInt(R.styleable.carVolumeItems_item_usage, -1); 394 if (usage >= 0) { 395 VolumeItem volumeItem = new VolumeItem(); 396 volumeItem.rank = rank; 397 volumeItem.icon = item.getResourceId(R.styleable.carVolumeItems_item_icon, 398 0); 399 mVolumeItems.put(usage, volumeItem); 400 rank++; 401 } 402 item.recycle(); 403 } 404 } 405 } catch (XmlPullParserException | IOException e) { 406 Log.e(TAG, "Error parsing volume groups configuration", e); 407 } 408 } 409 410 private VolumeItem getVolumeItemForUsages(int[] usages) { 411 int rank = Integer.MAX_VALUE; 412 VolumeItem result = null; 413 for (int usage : usages) { 414 VolumeItem volumeItem = mVolumeItems.get(usage); 415 if (volumeItem.rank < rank) { 416 rank = volumeItem.rank; 417 result = volumeItem; 418 } 419 } 420 return result; 421 } 422 423 private CarVolumeItem addCarVolumeListItem(VolumeItem volumeItem, int volumeGroupId, 424 int supplementalIconId, 425 @Nullable View.OnClickListener supplementalIconOnClickListener) { 426 CarVolumeItem carVolumeItem = new CarVolumeItem(); 427 carVolumeItem.setMax(getMaxSeekbarValue(mCarAudioManager, volumeGroupId)); 428 int color = mContext.getResources().getColor(R.color.car_volume_dialog_tint); 429 int progress = getSeekbarValue(mCarAudioManager, volumeGroupId); 430 carVolumeItem.setProgress(progress); 431 carVolumeItem.setOnSeekBarChangeListener( 432 new CarVolumeDialogImpl.VolumeSeekBarChangeListener(volumeGroupId, 433 mCarAudioManager)); 434 Drawable primaryIcon = mContext.getResources().getDrawable(volumeItem.icon); 435 primaryIcon.mutate().setTint(color); 436 carVolumeItem.setPrimaryIcon(primaryIcon); 437 if (supplementalIconId != 0) { 438 Drawable supplementalIcon = mContext.getResources().getDrawable(supplementalIconId); 439 supplementalIcon.mutate().setTint(color); 440 carVolumeItem.setSupplementalIcon(supplementalIcon, 441 /* showSupplementalIconDivider= */ true); 442 carVolumeItem.setSupplementalIconListener(supplementalIconOnClickListener); 443 } else { 444 carVolumeItem.setSupplementalIcon(/* drawable= */ null, 445 /* showSupplementalIconDivider= */ false); 446 } 447 carVolumeItem.setGroupId(volumeGroupId); 448 mCarVolumeLineItems.add(carVolumeItem); 449 volumeItem.carVolumeItem = carVolumeItem; 450 volumeItem.progress = progress; 451 return carVolumeItem; 452 } 453 454 private VolumeItem findVolumeItem(CarVolumeItem targetItem) { 455 for (int i = 0; i < mVolumeItems.size(); ++i) { 456 VolumeItem volumeItem = mVolumeItems.valueAt(i); 457 if (volumeItem.carVolumeItem == targetItem) { 458 return volumeItem; 459 } 460 } 461 return null; 462 } 463 464 private void cleanupAudioManager() { 465 mCarAudioManager.unregisterCarVolumeCallback(mVolumeChangeCallback); 466 mCarVolumeLineItems.clear(); 467 mCarAudioManager = null; 468 } 469 470 /** 471 * Wrapper class which contains information of each volume group. 472 */ 473 private static class VolumeItem { 474 475 private int rank; 476 private boolean defaultItem = false; 477 @DrawableRes 478 private int icon; 479 private CarVolumeItem carVolumeItem; 480 private int progress; 481 } 482 483 private final class H extends Handler { 484 485 private static final int SHOW = 1; 486 private static final int DISMISS = 2; 487 488 private H() { 489 super(Looper.getMainLooper()); 490 } 491 492 @Override 493 public void handleMessage(Message msg) { 494 switch (msg.what) { 495 case SHOW: 496 showH(msg.arg1); 497 break; 498 case DISMISS: 499 dismissH(msg.arg1); 500 break; 501 default: 502 } 503 } 504 } 505 506 private final class CustomDialog extends Dialog implements DialogInterface { 507 508 private CustomDialog(Context context) { 509 super(context, com.android.systemui.R.style.qs_theme); 510 } 511 512 @Override 513 public boolean dispatchTouchEvent(MotionEvent ev) { 514 rescheduleTimeoutH(); 515 return super.dispatchTouchEvent(ev); 516 } 517 518 @Override 519 protected void onStart() { 520 super.setCanceledOnTouchOutside(true); 521 super.onStart(); 522 } 523 524 @Override 525 protected void onStop() { 526 super.onStop(); 527 } 528 529 @Override 530 public boolean onTouchEvent(MotionEvent event) { 531 if (isShowing()) { 532 if (event.getAction() == MotionEvent.ACTION_OUTSIDE) { 533 mHandler.obtainMessage( 534 H.DISMISS, Events.DISMISS_REASON_TOUCH_OUTSIDE).sendToTarget(); 535 return true; 536 } 537 } 538 return false; 539 } 540 } 541 542 private final class ExpandIconListener implements View.OnClickListener { 543 @Override 544 public void onClick(final View v) { 545 mExpandIcon = v; 546 toggleDialogExpansion(true); 547 } 548 } 549 550 private void toggleDialogExpansion(boolean isClicked) { 551 mExpanded = !mExpanded; 552 Animator inAnimator; 553 if (mExpanded) { 554 for (int groupId = 0; groupId < mAvailableVolumeItems.size(); ++groupId) { 555 if (groupId != mCurrentlyDisplayingGroupId) { 556 VolumeItem volumeItem = mAvailableVolumeItems.get(groupId); 557 addCarVolumeListItem(volumeItem, groupId, 0, null); 558 } 559 } 560 inAnimator = AnimatorInflater.loadAnimator( 561 mContext, R.anim.car_arrow_fade_in_rotate_up); 562 563 } else { 564 // Only keeping the default stream if it is not expended. 565 Iterator itr = mCarVolumeLineItems.iterator(); 566 while (itr.hasNext()) { 567 CarVolumeItem carVolumeItem = (CarVolumeItem) itr.next(); 568 if (carVolumeItem.getGroupId() != mCurrentlyDisplayingGroupId) { 569 itr.remove(); 570 } 571 } 572 inAnimator = AnimatorInflater.loadAnimator( 573 mContext, R.anim.car_arrow_fade_in_rotate_down); 574 } 575 576 Animator outAnimator = AnimatorInflater.loadAnimator( 577 mContext, R.anim.car_arrow_fade_out); 578 inAnimator.setStartDelay(ARROW_FADE_IN_START_DELAY_IN_MILLIS); 579 AnimatorSet animators = new AnimatorSet(); 580 animators.playTogether(outAnimator, inAnimator); 581 if (!isClicked) { 582 // Do not animate when the state is called to reset the dialogs view and not clicked 583 // by user. 584 animators.setDuration(0); 585 } 586 animators.setTarget(mExpandIcon); 587 animators.start(); 588 mVolumeItemsAdapter.notifyDataSetChanged(); 589 } 590 591 private final class VolumeSeekBarChangeListener implements OnSeekBarChangeListener { 592 593 private final int mVolumeGroupId; 594 private final CarAudioManager mCarAudioManager; 595 596 private VolumeSeekBarChangeListener(int volumeGroupId, CarAudioManager carAudioManager) { 597 mVolumeGroupId = volumeGroupId; 598 mCarAudioManager = carAudioManager; 599 } 600 601 @Override 602 public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { 603 if (!fromUser) { 604 // For instance, if this event is originated from AudioService, 605 // we can ignore it as it has already been handled and doesn't need to be 606 // sent back down again. 607 return; 608 } 609 try { 610 if (mCarAudioManager == null) { 611 Log.w(TAG, "Ignoring volume change event because the car isn't connected"); 612 return; 613 } 614 mAvailableVolumeItems.get(mVolumeGroupId).progress = progress; 615 mAvailableVolumeItems.get( 616 mVolumeGroupId).carVolumeItem.setProgress(progress); 617 mCarAudioManager.setGroupVolume(mVolumeGroupId, progress, 0); 618 } catch (CarNotConnectedException e) { 619 Log.e(TAG, "Car is not connected!", e); 620 } 621 } 622 623 @Override 624 public void onStartTrackingTouch(SeekBar seekBar) { 625 } 626 627 @Override 628 public void onStopTrackingTouch(SeekBar seekBar) { 629 } 630 } 631 } 632