Home | History | Annotate | Download | only in display
      1 /*
      2  * Copyright (C) 2016 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.server.display;
     18 
     19 import android.animation.Animator;
     20 import android.animation.AnimatorListenerAdapter;
     21 import android.animation.TypeEvaluator;
     22 import android.animation.ValueAnimator;
     23 import android.annotation.NonNull;
     24 import android.annotation.Nullable;
     25 import android.app.AlarmManager;
     26 import android.content.BroadcastReceiver;
     27 import android.content.ContentResolver;
     28 import android.content.Context;
     29 import android.content.Intent;
     30 import android.content.IntentFilter;
     31 import android.database.ContentObserver;
     32 import android.net.Uri;
     33 import android.opengl.Matrix;
     34 import android.os.Handler;
     35 import android.os.Looper;
     36 import android.os.RemoteException;
     37 import android.os.UserHandle;
     38 import android.provider.Settings.Secure;
     39 import android.service.vr.IVrManager;
     40 import android.service.vr.IVrStateCallbacks;
     41 import android.util.MathUtils;
     42 import android.util.Slog;
     43 import android.view.animation.AnimationUtils;
     44 
     45 import com.android.internal.app.NightDisplayController;
     46 import com.android.server.SystemService;
     47 import com.android.server.twilight.TwilightListener;
     48 import com.android.server.twilight.TwilightManager;
     49 import com.android.server.twilight.TwilightState;
     50 
     51 import java.time.LocalDateTime;
     52 import java.time.LocalTime;
     53 import java.time.ZoneId;
     54 import java.util.concurrent.atomic.AtomicBoolean;
     55 import java.util.TimeZone;
     56 
     57 import com.android.internal.R;
     58 
     59 import static com.android.server.display.DisplayTransformManager.LEVEL_COLOR_MATRIX_NIGHT_DISPLAY;
     60 
     61 /**
     62  * Tints the display at night.
     63  */
     64 public final class NightDisplayService extends SystemService
     65         implements NightDisplayController.Callback {
     66 
     67     private static final String TAG = "NightDisplayService";
     68 
     69     /**
     70      * The transition time, in milliseconds, for Night Display to turn on/off.
     71      */
     72     private static final long TRANSITION_DURATION = 3000L;
     73 
     74     /**
     75      * The identity matrix, used if one of the given matrices is {@code null}.
     76      */
     77     private static final float[] MATRIX_IDENTITY = new float[16];
     78     static {
     79         Matrix.setIdentityM(MATRIX_IDENTITY, 0);
     80     }
     81 
     82     /**
     83      * Evaluator used to animate color matrix transitions.
     84      */
     85     private static final ColorMatrixEvaluator COLOR_MATRIX_EVALUATOR = new ColorMatrixEvaluator();
     86 
     87     private final Handler mHandler;
     88     private final AtomicBoolean mIgnoreAllColorMatrixChanges = new AtomicBoolean();
     89     private final IVrStateCallbacks mVrStateCallbacks = new IVrStateCallbacks.Stub() {
     90         @Override
     91         public void onVrStateChanged(final boolean enabled) {
     92             // Turn off all night mode display stuff while device is in VR mode.
     93             mIgnoreAllColorMatrixChanges.set(enabled);
     94             mHandler.post(new Runnable() {
     95                 @Override
     96                 public void run() {
     97                     // Cancel in-progress animations
     98                     if (mColorMatrixAnimator != null) {
     99                         mColorMatrixAnimator.cancel();
    100                     }
    101 
    102                     final DisplayTransformManager dtm =
    103                             getLocalService(DisplayTransformManager.class);
    104                     if (enabled) {
    105                         dtm.setColorMatrix(LEVEL_COLOR_MATRIX_NIGHT_DISPLAY, MATRIX_IDENTITY);
    106                     } else if (mController != null && mController.isActivated()) {
    107                         setMatrix(mController.getColorTemperature(), mMatrixNight);
    108                         dtm.setColorMatrix(LEVEL_COLOR_MATRIX_NIGHT_DISPLAY, mMatrixNight);
    109                     }
    110                 }
    111             });
    112         }
    113     };
    114 
    115     private float[] mMatrixNight = new float[16];
    116 
    117     private final float[] mColorTempCoefficients = new float[9];
    118 
    119     private int mCurrentUser = UserHandle.USER_NULL;
    120     private ContentObserver mUserSetupObserver;
    121     private boolean mBootCompleted;
    122 
    123     private NightDisplayController mController;
    124     private ValueAnimator mColorMatrixAnimator;
    125     private Boolean mIsActivated;
    126     private AutoMode mAutoMode;
    127 
    128     public NightDisplayService(Context context) {
    129         super(context);
    130         mHandler = new Handler(Looper.getMainLooper());
    131     }
    132 
    133     @Override
    134     public void onStart() {
    135         // Nothing to publish.
    136     }
    137 
    138     @Override
    139     public void onBootPhase(int phase) {
    140         if (phase >= PHASE_SYSTEM_SERVICES_READY) {
    141             final IVrManager vrManager = IVrManager.Stub.asInterface(
    142                     getBinderService(Context.VR_SERVICE));
    143             if (vrManager != null) {
    144                 try {
    145                     vrManager.registerListener(mVrStateCallbacks);
    146                 } catch (RemoteException e) {
    147                     Slog.e(TAG, "Failed to register VR mode state listener: " + e);
    148                 }
    149             }
    150         }
    151 
    152         if (phase >= PHASE_BOOT_COMPLETED) {
    153             mBootCompleted = true;
    154 
    155             // Register listeners now that boot is complete.
    156             if (mCurrentUser != UserHandle.USER_NULL && mUserSetupObserver == null) {
    157                 setUp();
    158             }
    159         }
    160     }
    161 
    162     @Override
    163     public void onStartUser(int userHandle) {
    164         super.onStartUser(userHandle);
    165 
    166         if (mCurrentUser == UserHandle.USER_NULL) {
    167             onUserChanged(userHandle);
    168         }
    169     }
    170 
    171     @Override
    172     public void onSwitchUser(int userHandle) {
    173         super.onSwitchUser(userHandle);
    174 
    175         onUserChanged(userHandle);
    176     }
    177 
    178     @Override
    179     public void onStopUser(int userHandle) {
    180         super.onStopUser(userHandle);
    181 
    182         if (mCurrentUser == userHandle) {
    183             onUserChanged(UserHandle.USER_NULL);
    184         }
    185     }
    186 
    187     private void onUserChanged(int userHandle) {
    188         final ContentResolver cr = getContext().getContentResolver();
    189 
    190         if (mCurrentUser != UserHandle.USER_NULL) {
    191             if (mUserSetupObserver != null) {
    192                 cr.unregisterContentObserver(mUserSetupObserver);
    193                 mUserSetupObserver = null;
    194             } else if (mBootCompleted) {
    195                 tearDown();
    196             }
    197         }
    198 
    199         mCurrentUser = userHandle;
    200 
    201         if (mCurrentUser != UserHandle.USER_NULL) {
    202             if (!isUserSetupCompleted(cr, mCurrentUser)) {
    203                 mUserSetupObserver = new ContentObserver(mHandler) {
    204                     @Override
    205                     public void onChange(boolean selfChange, Uri uri) {
    206                         if (isUserSetupCompleted(cr, mCurrentUser)) {
    207                             cr.unregisterContentObserver(this);
    208                             mUserSetupObserver = null;
    209 
    210                             if (mBootCompleted) {
    211                                 setUp();
    212                             }
    213                         }
    214                     }
    215                 };
    216                 cr.registerContentObserver(Secure.getUriFor(Secure.USER_SETUP_COMPLETE),
    217                         false /* notifyForDescendents */, mUserSetupObserver, mCurrentUser);
    218             } else if (mBootCompleted) {
    219                 setUp();
    220             }
    221         }
    222     }
    223 
    224     private static boolean isUserSetupCompleted(ContentResolver cr, int userHandle) {
    225         return Secure.getIntForUser(cr, Secure.USER_SETUP_COMPLETE, 0, userHandle) == 1;
    226     }
    227 
    228     private void setUp() {
    229         Slog.d(TAG, "setUp: currentUser=" + mCurrentUser);
    230 
    231         // Create a new controller for the current user and start listening for changes.
    232         mController = new NightDisplayController(getContext(), mCurrentUser);
    233         mController.setListener(this);
    234 
    235         setCoefficientMatrix(getContext());
    236 
    237         // Prepare color transformation matrix.
    238         setMatrix(mController.getColorTemperature(), mMatrixNight);
    239 
    240         // Initialize the current auto mode.
    241         onAutoModeChanged(mController.getAutoMode());
    242 
    243         // Force the initialization current activated state.
    244         if (mIsActivated == null) {
    245             onActivated(mController.isActivated());
    246         }
    247 
    248         // Transition the screen to the current temperature.
    249         applyTint(false);
    250     }
    251 
    252     private void tearDown() {
    253         Slog.d(TAG, "tearDown: currentUser=" + mCurrentUser);
    254 
    255         if (mController != null) {
    256             mController.setListener(null);
    257             mController = null;
    258         }
    259 
    260         if (mAutoMode != null) {
    261             mAutoMode.onStop();
    262             mAutoMode = null;
    263         }
    264 
    265         if (mColorMatrixAnimator != null) {
    266             mColorMatrixAnimator.end();
    267             mColorMatrixAnimator = null;
    268         }
    269 
    270         mIsActivated = null;
    271     }
    272 
    273     @Override
    274     public void onActivated(boolean activated) {
    275         if (mIsActivated == null || mIsActivated != activated) {
    276             Slog.i(TAG, activated ? "Turning on night display" : "Turning off night display");
    277 
    278             mIsActivated = activated;
    279 
    280             if (mAutoMode != null) {
    281                 mAutoMode.onActivated(activated);
    282             }
    283 
    284             applyTint(false);
    285         }
    286     }
    287 
    288     @Override
    289     public void onAutoModeChanged(int autoMode) {
    290         Slog.d(TAG, "onAutoModeChanged: autoMode=" + autoMode);
    291 
    292         if (mAutoMode != null) {
    293             mAutoMode.onStop();
    294             mAutoMode = null;
    295         }
    296 
    297         if (autoMode == NightDisplayController.AUTO_MODE_CUSTOM) {
    298             mAutoMode = new CustomAutoMode();
    299         } else if (autoMode == NightDisplayController.AUTO_MODE_TWILIGHT) {
    300             mAutoMode = new TwilightAutoMode();
    301         }
    302 
    303         if (mAutoMode != null) {
    304             mAutoMode.onStart();
    305         }
    306     }
    307 
    308     @Override
    309     public void onCustomStartTimeChanged(LocalTime startTime) {
    310         Slog.d(TAG, "onCustomStartTimeChanged: startTime=" + startTime);
    311 
    312         if (mAutoMode != null) {
    313             mAutoMode.onCustomStartTimeChanged(startTime);
    314         }
    315     }
    316 
    317     @Override
    318     public void onCustomEndTimeChanged(LocalTime endTime) {
    319         Slog.d(TAG, "onCustomEndTimeChanged: endTime=" + endTime);
    320 
    321         if (mAutoMode != null) {
    322             mAutoMode.onCustomEndTimeChanged(endTime);
    323         }
    324     }
    325 
    326     @Override
    327     public void onColorTemperatureChanged(int colorTemperature) {
    328         setMatrix(colorTemperature, mMatrixNight);
    329         applyTint(true);
    330     }
    331 
    332     @Override
    333     public void onDisplayColorModeChanged(int colorMode) {
    334         final DisplayTransformManager dtm = getLocalService(DisplayTransformManager.class);
    335         dtm.setColorMode(colorMode);
    336 
    337         setCoefficientMatrix(getContext());
    338         setMatrix(mController.getColorTemperature(), mMatrixNight);
    339         if (mController.isActivated()) {
    340             applyTint(true);
    341         }
    342     }
    343 
    344     private void setCoefficientMatrix(Context context) {
    345         final boolean isNative = DisplayTransformManager.isNativeModeEnabled();
    346         final String[] coefficients = context.getResources().getStringArray(isNative ?
    347             R.array.config_nightDisplayColorTemperatureCoefficientsNative
    348             : R.array.config_nightDisplayColorTemperatureCoefficients);
    349         for (int i = 0; i < 9 && i < coefficients.length; i++) {
    350             mColorTempCoefficients[i] = Float.parseFloat(coefficients[i]);
    351         }
    352     }
    353 
    354     /**
    355      * Applies current color temperature matrix, or removes it if deactivated.
    356      *
    357      * @param immediate {@code true} skips transition animation
    358      */
    359     private void applyTint(boolean immediate) {
    360         // Cancel the old animator if still running.
    361         if (mColorMatrixAnimator != null) {
    362             mColorMatrixAnimator.cancel();
    363         }
    364 
    365         // Don't do any color matrix change animations if we are ignoring them anyway.
    366         if (mIgnoreAllColorMatrixChanges.get()) {
    367             return;
    368         }
    369 
    370         final DisplayTransformManager dtm = getLocalService(DisplayTransformManager.class);
    371         final float[] from = dtm.getColorMatrix(LEVEL_COLOR_MATRIX_NIGHT_DISPLAY);
    372         final float[] to = mIsActivated ? mMatrixNight : MATRIX_IDENTITY;
    373 
    374         if (immediate) {
    375             dtm.setColorMatrix(LEVEL_COLOR_MATRIX_NIGHT_DISPLAY, to);
    376         } else {
    377             mColorMatrixAnimator = ValueAnimator.ofObject(COLOR_MATRIX_EVALUATOR,
    378                     from == null ? MATRIX_IDENTITY : from, to);
    379             mColorMatrixAnimator.setDuration(TRANSITION_DURATION);
    380             mColorMatrixAnimator.setInterpolator(AnimationUtils.loadInterpolator(
    381                     getContext(), android.R.interpolator.fast_out_slow_in));
    382             mColorMatrixAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    383                 @Override
    384                 public void onAnimationUpdate(ValueAnimator animator) {
    385                     final float[] value = (float[]) animator.getAnimatedValue();
    386                     dtm.setColorMatrix(LEVEL_COLOR_MATRIX_NIGHT_DISPLAY, value);
    387                 }
    388             });
    389             mColorMatrixAnimator.addListener(new AnimatorListenerAdapter() {
    390 
    391                 private boolean mIsCancelled;
    392 
    393                 @Override
    394                 public void onAnimationCancel(Animator animator) {
    395                     mIsCancelled = true;
    396                 }
    397 
    398                 @Override
    399                 public void onAnimationEnd(Animator animator) {
    400                     if (!mIsCancelled) {
    401                         // Ensure final color matrix is set at the end of the animation. If the
    402                         // animation is cancelled then don't set the final color matrix so the new
    403                         // animator can pick up from where this one left off.
    404                         dtm.setColorMatrix(LEVEL_COLOR_MATRIX_NIGHT_DISPLAY, to);
    405                     }
    406                     mColorMatrixAnimator = null;
    407                 }
    408             });
    409             mColorMatrixAnimator.start();
    410         }
    411     }
    412 
    413     /**
    414      * Set the color transformation {@code MATRIX_NIGHT} to the given color temperature.
    415      *
    416      * @param colorTemperature color temperature in Kelvin
    417      * @param outTemp          the 4x4 display transformation matrix for that color temperature
    418      */
    419     private void setMatrix(int colorTemperature, float[] outTemp) {
    420         if (outTemp.length != 16) {
    421             Slog.d(TAG, "The display transformation matrix must be 4x4");
    422             return;
    423         }
    424 
    425         Matrix.setIdentityM(outTemp, 0);
    426 
    427         final float squareTemperature = colorTemperature * colorTemperature;
    428         final float red = squareTemperature * mColorTempCoefficients[0]
    429                 + colorTemperature * mColorTempCoefficients[1] + mColorTempCoefficients[2];
    430         final float green = squareTemperature * mColorTempCoefficients[3]
    431                 + colorTemperature * mColorTempCoefficients[4] + mColorTempCoefficients[5];
    432         final float blue = squareTemperature * mColorTempCoefficients[6]
    433                 + colorTemperature * mColorTempCoefficients[7] + mColorTempCoefficients[8];
    434         outTemp[0] = red;
    435         outTemp[5] = green;
    436         outTemp[10] = blue;
    437     }
    438 
    439     /**
    440      * Returns the first date time corresponding to the local time that occurs before the
    441      * provided date time.
    442      *
    443      * @param compareTime the LocalDateTime to compare against
    444      * @return the prior LocalDateTime corresponding to this local time
    445      */
    446     public static LocalDateTime getDateTimeBefore(LocalTime localTime, LocalDateTime compareTime) {
    447         final LocalDateTime ldt = LocalDateTime.of(compareTime.getYear(), compareTime.getMonth(),
    448                 compareTime.getDayOfMonth(), localTime.getHour(), localTime.getMinute());
    449 
    450         // Check if the local time has passed, if so return the same time yesterday.
    451         return ldt.isAfter(compareTime) ? ldt.minusDays(1) : ldt;
    452     }
    453 
    454     /**
    455      * Returns the first date time corresponding to this local time that occurs after the
    456      * provided date time.
    457      *
    458      * @param compareTime the LocalDateTime to compare against
    459      * @return the next LocalDateTime corresponding to this local time
    460      */
    461     public static LocalDateTime getDateTimeAfter(LocalTime localTime, LocalDateTime compareTime) {
    462         final LocalDateTime ldt = LocalDateTime.of(compareTime.getYear(), compareTime.getMonth(),
    463                 compareTime.getDayOfMonth(), localTime.getHour(), localTime.getMinute());
    464 
    465         // Check if the local time has passed, if so return the same time tomorrow.
    466         return ldt.isBefore(compareTime) ? ldt.plusDays(1) : ldt;
    467     }
    468 
    469     private abstract class AutoMode implements NightDisplayController.Callback {
    470         public abstract void onStart();
    471 
    472         public abstract void onStop();
    473     }
    474 
    475     private class CustomAutoMode extends AutoMode implements AlarmManager.OnAlarmListener {
    476 
    477         private final AlarmManager mAlarmManager;
    478         private final BroadcastReceiver mTimeChangedReceiver;
    479 
    480         private LocalTime mStartTime;
    481         private LocalTime mEndTime;
    482 
    483         private LocalDateTime mLastActivatedTime;
    484 
    485         CustomAutoMode() {
    486             mAlarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE);
    487             mTimeChangedReceiver = new BroadcastReceiver() {
    488                 @Override
    489                 public void onReceive(Context context, Intent intent) {
    490                     updateActivated();
    491                 }
    492             };
    493         }
    494 
    495         private void updateActivated() {
    496             final LocalDateTime now = LocalDateTime.now();
    497             final LocalDateTime start = getDateTimeBefore(mStartTime, now);
    498             final LocalDateTime end = getDateTimeAfter(mEndTime, start);
    499             boolean activate = now.isBefore(end);
    500 
    501             if (mLastActivatedTime != null) {
    502                 // Maintain the existing activated state if within the current period.
    503                 if (mLastActivatedTime.isBefore(now) && mLastActivatedTime.isAfter(start)
    504                         && (mLastActivatedTime.isAfter(end) || now.isBefore(end))) {
    505                     activate = mController.isActivated();
    506                 }
    507             }
    508 
    509             if (mIsActivated == null || mIsActivated != activate) {
    510                 mController.setActivated(activate);
    511             }
    512 
    513             updateNextAlarm(mIsActivated, now);
    514         }
    515 
    516         private void updateNextAlarm(@Nullable Boolean activated, @NonNull LocalDateTime now) {
    517             if (activated != null) {
    518                 final LocalDateTime next = activated ? getDateTimeAfter(mEndTime, now)
    519                         : getDateTimeAfter(mStartTime, now);
    520                 final long millis = next.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
    521                 mAlarmManager.setExact(AlarmManager.RTC, millis, TAG, this, null);
    522             }
    523         }
    524 
    525         @Override
    526         public void onStart() {
    527             final IntentFilter intentFilter = new IntentFilter(Intent.ACTION_TIME_CHANGED);
    528             intentFilter.addAction(Intent.ACTION_TIMEZONE_CHANGED);
    529             getContext().registerReceiver(mTimeChangedReceiver, intentFilter);
    530 
    531             mStartTime = mController.getCustomStartTime();
    532             mEndTime = mController.getCustomEndTime();
    533 
    534             mLastActivatedTime = mController.getLastActivatedTime();
    535 
    536             // Force an update to initialize state.
    537             updateActivated();
    538         }
    539 
    540         @Override
    541         public void onStop() {
    542             getContext().unregisterReceiver(mTimeChangedReceiver);
    543 
    544             mAlarmManager.cancel(this);
    545             mLastActivatedTime = null;
    546         }
    547 
    548         @Override
    549         public void onActivated(boolean activated) {
    550             mLastActivatedTime = mController.getLastActivatedTime();
    551             updateNextAlarm(activated, LocalDateTime.now());
    552         }
    553 
    554         @Override
    555         public void onCustomStartTimeChanged(LocalTime startTime) {
    556             mStartTime = startTime;
    557             mLastActivatedTime = null;
    558             updateActivated();
    559         }
    560 
    561         @Override
    562         public void onCustomEndTimeChanged(LocalTime endTime) {
    563             mEndTime = endTime;
    564             mLastActivatedTime = null;
    565             updateActivated();
    566         }
    567 
    568         @Override
    569         public void onAlarm() {
    570             Slog.d(TAG, "onAlarm");
    571             updateActivated();
    572         }
    573     }
    574 
    575     private class TwilightAutoMode extends AutoMode implements TwilightListener {
    576 
    577         private final TwilightManager mTwilightManager;
    578 
    579         TwilightAutoMode() {
    580             mTwilightManager = getLocalService(TwilightManager.class);
    581         }
    582 
    583         private void updateActivated(TwilightState state) {
    584             if (state == null) {
    585                 // If there isn't a valid TwilightState then just keep the current activated
    586                 // state.
    587                 return;
    588             }
    589 
    590             boolean activate = state.isNight();
    591             final LocalDateTime lastActivatedTime = mController.getLastActivatedTime();
    592             if (lastActivatedTime != null) {
    593                 final LocalDateTime now = LocalDateTime.now();
    594                 final LocalDateTime sunrise = state.sunrise();
    595                 final LocalDateTime sunset = state.sunset();
    596                 // Maintain the existing activated state if within the current period.
    597                 if (lastActivatedTime.isBefore(now) && (lastActivatedTime.isBefore(sunrise)
    598                         ^ lastActivatedTime.isBefore(sunset))) {
    599                     activate = mController.isActivated();
    600                 }
    601             }
    602 
    603             if (mIsActivated == null || mIsActivated != activate) {
    604                 mController.setActivated(activate);
    605             }
    606         }
    607 
    608         @Override
    609         public void onStart() {
    610             mTwilightManager.registerListener(this, mHandler);
    611 
    612             // Force an update to initialize state.
    613             updateActivated(mTwilightManager.getLastTwilightState());
    614         }
    615 
    616         @Override
    617         public void onStop() {
    618             mTwilightManager.unregisterListener(this);
    619         }
    620 
    621         @Override
    622         public void onActivated(boolean activated) {
    623         }
    624 
    625         @Override
    626         public void onTwilightStateChanged(@Nullable TwilightState state) {
    627             Slog.d(TAG, "onTwilightStateChanged: isNight="
    628                     + (state == null ? null : state.isNight()));
    629             updateActivated(state);
    630         }
    631     }
    632 
    633     /**
    634      * Interpolates between two 4x4 color transform matrices (in column-major order).
    635      */
    636     private static class ColorMatrixEvaluator implements TypeEvaluator<float[]> {
    637 
    638         /**
    639          * Result matrix returned by {@link #evaluate(float, float[], float[])}.
    640          */
    641         private final float[] mResultMatrix = new float[16];
    642 
    643         @Override
    644         public float[] evaluate(float fraction, float[] startValue, float[] endValue) {
    645             for (int i = 0; i < mResultMatrix.length; i++) {
    646                 mResultMatrix[i] = MathUtils.lerp(startValue[i], endValue[i], fraction);
    647             }
    648             return mResultMatrix;
    649         }
    650     }
    651 }
    652