AlertService.java revision 42ba5efed5945b0e96735ec9ca4b388ae35b56f7
1/* 2 * Copyright (C) 2008 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 17package com.android.calendar.alerts; 18 19import com.android.calendar.GeneralPreferences; 20import com.android.calendar.R; 21 22import android.app.AlarmManager; 23import android.app.Notification; 24import android.app.NotificationManager; 25import android.app.Service; 26import android.content.ContentResolver; 27import android.content.ContentUris; 28import android.content.ContentValues; 29import android.content.Context; 30import android.content.Intent; 31import android.content.SharedPreferences; 32import android.database.Cursor; 33import android.media.AudioManager; 34import android.net.Uri; 35import android.os.Bundle; 36import android.os.Handler; 37import android.os.HandlerThread; 38import android.os.IBinder; 39import android.os.Looper; 40import android.os.Message; 41import android.os.Process; 42import android.provider.CalendarContract; 43import android.provider.CalendarContract.Attendees; 44import android.provider.CalendarContract.CalendarAlerts; 45import android.text.TextUtils; 46import android.text.format.DateUtils; 47import android.util.Log; 48 49import java.util.ArrayList; 50import java.util.HashMap; 51 52/** 53 * This service is used to handle calendar event reminders. 54 */ 55public class AlertService extends Service { 56 static final boolean DEBUG = true; 57 private static final String TAG = "AlertService"; 58 59 private volatile Looper mServiceLooper; 60 private volatile ServiceHandler mServiceHandler; 61 62 private static final String[] ALERT_PROJECTION = new String[] { 63 CalendarAlerts._ID, // 0 64 CalendarAlerts.EVENT_ID, // 1 65 CalendarAlerts.STATE, // 2 66 CalendarAlerts.TITLE, // 3 67 CalendarAlerts.EVENT_LOCATION, // 4 68 CalendarAlerts.SELF_ATTENDEE_STATUS, // 5 69 CalendarAlerts.ALL_DAY, // 6 70 CalendarAlerts.ALARM_TIME, // 7 71 CalendarAlerts.MINUTES, // 8 72 CalendarAlerts.BEGIN, // 9 73 CalendarAlerts.END, // 10 74 CalendarAlerts.DESCRIPTION, // 11 75 }; 76 77 private static final int ALERT_INDEX_ID = 0; 78 private static final int ALERT_INDEX_EVENT_ID = 1; 79 private static final int ALERT_INDEX_STATE = 2; 80 private static final int ALERT_INDEX_TITLE = 3; 81 private static final int ALERT_INDEX_EVENT_LOCATION = 4; 82 private static final int ALERT_INDEX_SELF_ATTENDEE_STATUS = 5; 83 private static final int ALERT_INDEX_ALL_DAY = 6; 84 private static final int ALERT_INDEX_ALARM_TIME = 7; 85 private static final int ALERT_INDEX_MINUTES = 8; 86 private static final int ALERT_INDEX_BEGIN = 9; 87 private static final int ALERT_INDEX_END = 10; 88 private static final int ALERT_INDEX_DESCRIPTION = 11; 89 90 private static final String ACTIVE_ALERTS_SELECTION = "(" + CalendarAlerts.STATE + "=? OR " 91 + CalendarAlerts.STATE + "=?) AND " + CalendarAlerts.ALARM_TIME + "<="; 92 93 private static final String[] ACTIVE_ALERTS_SELECTION_ARGS = new String[] { 94 Integer.toString(CalendarAlerts.STATE_FIRED), 95 Integer.toString(CalendarAlerts.STATE_SCHEDULED) 96 }; 97 98 private static final String ACTIVE_ALERTS_SORT = "begin DESC, end DESC"; 99 100 private static final String DISMISS_OLD_SELECTION = CalendarAlerts.END + "<? AND " 101 + CalendarAlerts.STATE + "=?"; 102 103 void processMessage(Message msg) { 104 Bundle bundle = (Bundle) msg.obj; 105 106 // On reboot, update the notification bar with the contents of the 107 // CalendarAlerts table. 108 String action = bundle.getString("action"); 109 if (DEBUG) { 110 Log.d(TAG, bundle.getLong(android.provider.CalendarContract.CalendarAlerts.ALARM_TIME) 111 + " Action = " + action); 112 } 113 114 if (action.equals(Intent.ACTION_BOOT_COMPLETED) 115 || action.equals(Intent.ACTION_TIME_CHANGED)) { 116 doTimeChanged(); 117 return; 118 } 119 120 if (!action.equals(android.provider.CalendarContract.ACTION_EVENT_REMINDER) 121 && !action.equals(Intent.ACTION_LOCALE_CHANGED) 122 && !action.equals(AlertReceiver.ACTION_DISMISS_OLD_REMINDERS)) { 123 Log.w(TAG, "Invalid action: " + action); 124 return; 125 } 126 if (action.equals(AlertReceiver.ACTION_DISMISS_OLD_REMINDERS)) { 127 dismissOldAlerts(this); 128 } 129 130 if (action.equals(android.provider.CalendarContract.ACTION_EVENT_REMINDER) && 131 bundle.getBoolean(AlertUtils.QUIET_UPDATE_KEY)) { 132 updateAlertNotification(this, true); 133 } else { 134 updateAlertNotification(this, false); 135 } 136 } 137 138 static void dismissOldAlerts(Context context) { 139 ContentResolver cr = context.getContentResolver(); 140 final long currentTime = System.currentTimeMillis(); 141 ContentValues vals = new ContentValues(); 142 vals.put(CalendarAlerts.STATE, CalendarAlerts.STATE_DISMISSED); 143 cr.update(CalendarAlerts.CONTENT_URI, vals, DISMISS_OLD_SELECTION, new String[] { 144 Long.toString(currentTime), Integer.toString(CalendarAlerts.STATE_SCHEDULED) 145 }); 146 } 147 148 static boolean updateAlertNotification(Context context, boolean quietUpdate) { 149 ContentResolver cr = context.getContentResolver(); 150 NotificationManager nm = 151 (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); 152 final long currentTime = System.currentTimeMillis(); 153 SharedPreferences prefs = GeneralPreferences.getSharedPreferences(context); 154 155 boolean doAlert = prefs.getBoolean(GeneralPreferences.KEY_ALERTS, true); 156 if (!doAlert) { 157 if (DEBUG) { 158 Log.d(TAG, "alert preference is OFF"); 159 } 160 161 // If we shouldn't be showing notifications cancel any existing ones 162 // and return. 163 nm.cancelAll(); 164 return true; 165 } 166 167 Cursor alertCursor = cr.query(CalendarAlerts.CONTENT_URI, ALERT_PROJECTION, 168 (ACTIVE_ALERTS_SELECTION + currentTime), ACTIVE_ALERTS_SELECTION_ARGS, 169 ACTIVE_ALERTS_SORT); 170 171 if (alertCursor == null || alertCursor.getCount() == 0) { 172 if (alertCursor != null) { 173 alertCursor.close(); 174 } 175 176 if (DEBUG) Log.d(TAG, "No fired or scheduled alerts"); 177 nm.cancelAll(); 178 return false; 179 } 180 181 if (DEBUG) { 182 Log.d(TAG, "alert count:" + alertCursor.getCount()); 183 } 184 185 HashMap<Long, Long> eventIds = new HashMap<Long, Long>(); 186 int numFired = 0; 187 ArrayList<NotificationInfo> currentEvents = new ArrayList<NotificationInfo>(); 188 ArrayList<NotificationInfo> futureEvents = new ArrayList<NotificationInfo>(); 189 ArrayList<NotificationInfo> expiredEvents = new ArrayList<NotificationInfo>(); 190 String expiredDigestTitle = null; 191 try { 192 while (alertCursor.moveToNext()) { 193 final long alertId = alertCursor.getLong(ALERT_INDEX_ID); 194 final long eventId = alertCursor.getLong(ALERT_INDEX_EVENT_ID); 195 final int minutes = alertCursor.getInt(ALERT_INDEX_MINUTES); 196 final String eventName = alertCursor.getString(ALERT_INDEX_TITLE); 197 final String description = alertCursor.getString(ALERT_INDEX_DESCRIPTION); 198 final String location = alertCursor.getString(ALERT_INDEX_EVENT_LOCATION); 199 final int status = alertCursor.getInt(ALERT_INDEX_SELF_ATTENDEE_STATUS); 200 final boolean declined = status == Attendees.ATTENDEE_STATUS_DECLINED; 201 final long beginTime = alertCursor.getLong(ALERT_INDEX_BEGIN); 202 final long endTime = alertCursor.getLong(ALERT_INDEX_END); 203 final Uri alertUri = ContentUris 204 .withAppendedId(CalendarAlerts.CONTENT_URI, alertId); 205 final long alarmTime = alertCursor.getLong(ALERT_INDEX_ALARM_TIME); 206 int state = alertCursor.getInt(ALERT_INDEX_STATE); 207 final boolean allDay = alertCursor.getInt(ALERT_INDEX_ALL_DAY) != 0; 208 209 if (DEBUG) { 210 Log.d(TAG, "alarmTime:" + alarmTime + " alertId:" + alertId 211 + " eventId:" + eventId + " state: " + state + " minutes:" + minutes 212 + " declined:" + declined + " beginTime:" + beginTime 213 + " endTime:" + endTime); 214 } 215 216 ContentValues values = new ContentValues(); 217 int newState = -1; 218 219 // Uncomment for the behavior of clearing out alerts after the 220 // events ended. b/1880369 221 // 222 // if (endTime < currentTime) { 223 // newState = CalendarAlerts.DISMISSED; 224 // } else 225 226 // Remove declined events 227 if (!declined) { 228 if (state == CalendarAlerts.STATE_SCHEDULED) { 229 newState = CalendarAlerts.STATE_FIRED; 230 numFired++; 231 232 // Record the received time in the CalendarAlerts table. 233 // This is useful for finding bugs that cause alarms to be 234 // missed or delayed. 235 values.put(CalendarAlerts.RECEIVED_TIME, currentTime); 236 } 237 } else { 238 newState = CalendarAlerts.STATE_DISMISSED; 239 } 240 241 // Update row if state changed 242 if (newState != -1) { 243 values.put(CalendarAlerts.STATE, newState); 244 state = newState; 245 } 246 247 if (state == CalendarAlerts.STATE_FIRED) { 248 // Record the time posting to notification manager. 249 // This is used for debugging missed alarms. 250 values.put(CalendarAlerts.NOTIFY_TIME, currentTime); 251 } 252 253 // Write row to if anything changed 254 if (values.size() > 0) cr.update(alertUri, values, null, null); 255 256 if (state != CalendarAlerts.STATE_FIRED) { 257 continue; 258 } 259 260 // Pick an Event title for the notification panel by the latest 261 // alertTime and give prefer accepted events in case of ties. 262 int newStatus; 263 switch (status) { 264 case Attendees.ATTENDEE_STATUS_ACCEPTED: 265 newStatus = 2; 266 break; 267 case Attendees.ATTENDEE_STATUS_TENTATIVE: 268 newStatus = 1; 269 break; 270 default: 271 newStatus = 0; 272 } 273 274 // Don't count duplicate alerts for the same event 275 if (eventIds.put(eventId, beginTime) == null) { 276 NotificationInfo notificationInfo = new NotificationInfo(eventName, location, 277 description, beginTime, endTime, eventId, allDay, alertId); 278 279 if ((beginTime <= currentTime) && (endTime >= currentTime)) { 280 currentEvents.add(notificationInfo); 281 } else if (beginTime > currentTime) { 282 futureEvents.add(notificationInfo); 283 } else { 284 // TODO: Prioritize by "primary" calendar 285 // Assumes alerts are sorted by begin time in reverse 286 expiredEvents.add(0, notificationInfo); 287 if (!TextUtils.isEmpty(eventName)) { 288 if (expiredDigestTitle == null) { 289 expiredDigestTitle = eventName; 290 } else { 291 expiredDigestTitle = eventName + ", " + expiredDigestTitle; 292 } 293 } 294 } 295 } 296 } 297 } finally { 298 if (alertCursor != null) { 299 alertCursor.close(); 300 } 301 } 302 303 if (currentEvents.size() + futureEvents.size() + expiredEvents.size() == 0) { 304 nm.cancelAll(); 305 return true; 306 } 307 308 quietUpdate = quietUpdate || (numFired == 0); 309 boolean doPopup = numFired > 0 && 310 prefs.getBoolean(GeneralPreferences.KEY_ALERTS_POPUP, false); 311 boolean defaultVibrate = shouldUseDefaultVibrate(context, prefs); 312 String ringtone = quietUpdate ? null : prefs.getString( 313 GeneralPreferences.KEY_ALERTS_RINGTONE, null); 314 315 // Post the individual future events (higher priority). 316 for (NotificationInfo info : futureEvents) { 317 String summaryText = AlertUtils.formatTimeLocation(context, info.startMillis, 318 info.allDay, info.location); 319 postNotification(info, summaryText, context, quietUpdate, doPopup, defaultVibrate, 320 ringtone, true, nm); 321 } 322 323 // Post the individual concurrent events (lower priority). 324 for (NotificationInfo info : currentEvents) { 325 // TODO: Change to a relative time description like: "Started 40 minutes ago". 326 // This requires constant refreshing to the message as time goes. 327 String summaryText = AlertUtils.formatTimeLocation(context, info.startMillis, 328 info.allDay, info.location); 329 330 // Keep concurrent events high priority (to appear higher in the notification list) 331 // until 15 minutes into the event. 332 boolean highPriority = false; 333 if (currentTime < info.startMillis + (15 * 60 * 1000)) { 334 highPriority = true; 335 } 336 postNotification(info, summaryText, context, quietUpdate, (doPopup && highPriority), 337 defaultVibrate, ringtone, highPriority, nm); 338 } 339 340 // Post the expired events as 1 combined notification. 341 int numExpired = expiredEvents.size(); 342 if (numExpired > 0) { 343 Notification notification; 344 if (numExpired == 1) { 345 // If only 1 expired event, display an "old-style" basic alert. 346 NotificationInfo info = expiredEvents.get(0); 347 String summaryText = AlertUtils.formatTimeLocation(context, info.startMillis, 348 info.allDay, info.location); 349 notification = AlertReceiver.makeBasicNotification(context, info.eventName, 350 summaryText, info.startMillis, info.endMillis, info.eventId, 351 info.notificationId, false); 352 } else { 353 // Multiple expired events are listed in a digest. 354 notification = AlertReceiver.makeDigestNotification(context, 355 expiredEvents, expiredDigestTitle); 356 } 357 addNotificationOptions(notification, quietUpdate, null, defaultVibrate, ringtone); 358 359 // Remove any individual expired notifications before posting. 360 for (NotificationInfo expiredInfo : expiredEvents) { 361 nm.cancel(expiredInfo.notificationId); 362 } 363 364 // Post the new notification for the group. 365 nm.notify(AlertUtils.EXPIRED_GROUP_NOTIFICATION_ID, notification); 366 367 if (DEBUG) { 368 Log.d(TAG, "Posting digest alarm notification, numEvents:" + expiredEvents.size() 369 + ", notificationId:" + AlertUtils.EXPIRED_GROUP_NOTIFICATION_ID 370 + (quietUpdate ? ", quiet" : ", loud")); 371 } 372 } 373 return true; 374 } 375 376 private static void postNotification(NotificationInfo info, String summaryText, 377 Context context, boolean quietUpdate, boolean doPopup, boolean defaultVibrate, 378 String ringtone, boolean highPriority, NotificationManager notificationMgr) { 379 String tickerText = getTickerText(info.eventName, info.location); 380 Notification notification = AlertReceiver.makeExpandingNotification(context, 381 info.eventName, summaryText, info.description, info.startMillis, 382 info.endMillis, info.eventId, info.notificationId, doPopup, highPriority); 383 addNotificationOptions(notification, quietUpdate, tickerText, defaultVibrate, 384 ringtone); 385 notificationMgr.notify(info.notificationId, notification); 386 387 if (DEBUG) { 388 Log.d(TAG, "Posting individual alarm notification, eventId:" + info.eventId 389 + ", notificationId:" + info.notificationId 390 + (quietUpdate ? ", quiet" : ", loud") 391 + (highPriority ? ", high-priority" : "")); 392 } 393 } 394 395 private static String getTickerText(String eventName, String location) { 396 String tickerText = eventName; 397 if (!TextUtils.isEmpty(location)) { 398 tickerText = eventName + " - " + location; 399 } 400 return tickerText; 401 } 402 403 static class NotificationInfo { 404 String eventName; 405 String location; 406 String description; 407 long startMillis; 408 long endMillis; 409 long eventId; 410 int notificationId; 411 boolean allDay; 412 413 NotificationInfo(String eventName, String location, String description, long startMillis, 414 long endMillis, long eventId, boolean allDay, long alertId) { 415 this.eventName = eventName; 416 this.location = location; 417 this.description = description; 418 this.startMillis = startMillis; 419 this.endMillis = endMillis; 420 this.eventId = eventId; 421 this.allDay = allDay; 422 423 // Convert alert ID into the ID for posting notifications. Use hash so we don't 424 // have to worry about any limits (but handle the case of a collision with the ID 425 // reserved for representing the expired notification digest). 426 this.notificationId = Long.valueOf(alertId).hashCode(); 427 if (notificationId == AlertUtils.EXPIRED_GROUP_NOTIFICATION_ID) { 428 this.notificationId = Integer.MAX_VALUE; 429 } 430 } 431 } 432 433 private static boolean shouldUseDefaultVibrate(Context context, SharedPreferences prefs) { 434 // Find out the circumstances under which to vibrate. 435 // Migrate from pre-Froyo boolean setting if necessary. 436 String vibrateWhen; // "always" or "silent" or "never" 437 if(prefs.contains(GeneralPreferences.KEY_ALERTS_VIBRATE_WHEN)) 438 { 439 // Look up Froyo setting 440 vibrateWhen = 441 prefs.getString(GeneralPreferences.KEY_ALERTS_VIBRATE_WHEN, null); 442 } else if(prefs.contains(GeneralPreferences.KEY_ALERTS_VIBRATE)) { 443 // No Froyo setting. Migrate pre-Froyo setting to new Froyo-defined value. 444 boolean vibrate = 445 prefs.getBoolean(GeneralPreferences.KEY_ALERTS_VIBRATE, false); 446 vibrateWhen = vibrate ? 447 context.getString(R.string.prefDefault_alerts_vibrate_true) : 448 context.getString(R.string.prefDefault_alerts_vibrate_false); 449 } else { 450 // No setting. Use Froyo-defined default. 451 vibrateWhen = context.getString(R.string.prefDefault_alerts_vibrateWhen); 452 } 453 454 if (vibrateWhen.equals("always")) { 455 return true; 456 } 457 if (!vibrateWhen.equals("silent")) { 458 return false; 459 } 460 461 // Settings are to vibrate when silent. Return true if it is now silent. 462 AudioManager audioManager = 463 (AudioManager)context.getSystemService(Context.AUDIO_SERVICE); 464 return audioManager.getRingerMode() == AudioManager.RINGER_MODE_VIBRATE; 465 } 466 467 private static void addNotificationOptions(Notification notification, boolean quietUpdate, 468 String tickerText, boolean defaultVibrate, String reminderRingtone) { 469 notification.defaults |= Notification.DEFAULT_LIGHTS; 470 471 // Quietly update notification bar. Nothing new. Maybe something just got deleted. 472 if (!quietUpdate) { 473 // Flash ticker in status bar 474 if (!TextUtils.isEmpty(tickerText)) { 475 notification.tickerText = tickerText; 476 } 477 478 // Generate either a pop-up dialog, status bar notification, or 479 // neither. Pop-up dialog and status bar notification may include a 480 // sound, an alert, or both. A status bar notification also includes 481 // a toast. 482 if (defaultVibrate) { 483 notification.defaults |= Notification.DEFAULT_VIBRATE; 484 } 485 486 // Possibly generate a sound. If 'Silent' is chosen, the ringtone 487 // string will be empty. 488 notification.sound = TextUtils.isEmpty(reminderRingtone) ? null : Uri 489 .parse(reminderRingtone); 490 } 491 } 492 493 private void doTimeChanged() { 494 ContentResolver cr = getContentResolver(); 495 Object service = getSystemService(Context.ALARM_SERVICE); 496 AlarmManager manager = (AlarmManager) service; 497 // TODO Move this into Provider 498 rescheduleMissedAlarms(cr, this, manager); 499 updateAlertNotification(this, false); 500 } 501 502 private static final String SORT_ORDER_ALARMTIME_ASC = 503 CalendarContract.CalendarAlerts.ALARM_TIME + " ASC"; 504 505 private static final String WHERE_RESCHEDULE_MISSED_ALARMS = 506 CalendarContract.CalendarAlerts.STATE 507 + "=" 508 + CalendarContract.CalendarAlerts.STATE_SCHEDULED 509 + " AND " 510 + CalendarContract.CalendarAlerts.ALARM_TIME 511 + "<?" 512 + " AND " 513 + CalendarContract.CalendarAlerts.ALARM_TIME 514 + ">?" 515 + " AND " 516 + CalendarContract.CalendarAlerts.END + ">=?"; 517 518 /** 519 * Searches the CalendarAlerts table for alarms that should have fired but 520 * have not and then reschedules them. This method can be called at boot 521 * time to restore alarms that may have been lost due to a phone reboot. 522 * 523 * @param cr the ContentResolver 524 * @param context the Context 525 * @param manager the AlarmManager 526 */ 527 public static final void rescheduleMissedAlarms(ContentResolver cr, Context context, 528 AlarmManager manager) { 529 // Get all the alerts that have been scheduled but have not fired 530 // and should have fired by now and are not too old. 531 long now = System.currentTimeMillis(); 532 long ancient = now - DateUtils.DAY_IN_MILLIS; 533 String[] projection = new String[] { 534 CalendarContract.CalendarAlerts.ALARM_TIME, 535 }; 536 537 // TODO: construct an explicit SQL query so that we can add 538 // "GROUPBY" instead of doing a sort and de-dup 539 Cursor cursor = cr.query(CalendarAlerts.CONTENT_URI, projection, 540 WHERE_RESCHEDULE_MISSED_ALARMS, (new String[] { 541 Long.toString(now), Long.toString(ancient), Long.toString(now) 542 }), SORT_ORDER_ALARMTIME_ASC); 543 if (cursor == null) { 544 return; 545 } 546 547 if (DEBUG) { 548 Log.d(TAG, "missed alarms found: " + cursor.getCount()); 549 } 550 551 try { 552 long alarmTime = -1; 553 554 while (cursor.moveToNext()) { 555 long newAlarmTime = cursor.getLong(0); 556 if (alarmTime != newAlarmTime) { 557 if (DEBUG) { 558 Log.w(TAG, "rescheduling missed alarm. alarmTime: " + newAlarmTime); 559 } 560 AlertUtils.scheduleAlarm(context, manager, newAlarmTime); 561 alarmTime = newAlarmTime; 562 } 563 } 564 } finally { 565 cursor.close(); 566 } 567 } 568 569 private final class ServiceHandler extends Handler { 570 public ServiceHandler(Looper looper) { 571 super(looper); 572 } 573 574 @Override 575 public void handleMessage(Message msg) { 576 processMessage(msg); 577 // NOTE: We MUST not call stopSelf() directly, since we need to 578 // make sure the wake lock acquired by AlertReceiver is released. 579 AlertReceiver.finishStartingService(AlertService.this, msg.arg1); 580 } 581 } 582 583 @Override 584 public void onCreate() { 585 HandlerThread thread = new HandlerThread("AlertService", 586 Process.THREAD_PRIORITY_BACKGROUND); 587 thread.start(); 588 589 mServiceLooper = thread.getLooper(); 590 mServiceHandler = new ServiceHandler(mServiceLooper); 591 } 592 593 @Override 594 public int onStartCommand(Intent intent, int flags, int startId) { 595 if (intent != null) { 596 Message msg = mServiceHandler.obtainMessage(); 597 msg.arg1 = startId; 598 msg.obj = intent.getExtras(); 599 mServiceHandler.sendMessage(msg); 600 } 601 return START_REDELIVER_INTENT; 602 } 603 604 @Override 605 public void onDestroy() { 606 mServiceLooper.quit(); 607 } 608 609 @Override 610 public IBinder onBind(Intent intent) { 611 return null; 612 } 613} 614