CalendarAppWidgetService.java revision f9df037f350fad73659307ba05f230d2db69051a
1/* 2 * Copyright (C) 2009 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.widget; 18 19import android.app.AlarmManager; 20import android.app.PendingIntent; 21import android.appwidget.AppWidgetManager; 22import android.content.BroadcastReceiver; 23import android.content.ContentResolver; 24import android.content.Context; 25import android.content.Intent; 26import android.content.IntentFilter; 27import android.database.ContentObserver; 28import android.database.Cursor; 29import android.database.MatrixCursor; 30import android.net.Uri; 31import android.os.Handler; 32import android.provider.Calendar; 33import android.provider.Calendar.Attendees; 34import android.provider.Calendar.Calendars; 35import android.provider.Calendar.Events; 36import android.provider.Calendar.Instances; 37import android.text.TextUtils; 38import android.text.format.DateFormat; 39import android.text.format.DateUtils; 40import android.text.format.Time; 41import android.util.Log; 42import android.view.View; 43import android.widget.RemoteViews; 44import android.widget.RemoteViewsService; 45 46import com.google.common.annotations.VisibleForTesting; 47 48import com.android.calendar.R; 49import com.android.calendar.Utils; 50import com.android.calendar.widget.CalendarAppWidgetModel.EventInfo; 51 52import java.util.ArrayList; 53import java.util.List; 54import java.util.TimeZone; 55 56 57public class CalendarAppWidgetService extends RemoteViewsService { 58 private static final String TAG = "CalendarAppWidgetService"; 59 private static final boolean LOGD = false; 60 61 private static final int EVENT_MAX_COUNT = 10; 62 63 private static final String EVENT_SORT_ORDER = Instances.START_DAY + " ASC, " 64 + Instances.START_MINUTE + " ASC, " + Instances.END_DAY + " ASC, " 65 + Instances.END_MINUTE + " ASC LIMIT " + EVENT_MAX_COUNT; 66 67 // TODO can't use parameter here because provider is dropping them 68 private static final String EVENT_SELECTION = Calendars.SELECTED + "=1 AND " 69 + Instances.SELF_ATTENDEE_STATUS + "!=" + Attendees.ATTENDEE_STATUS_DECLINED; 70 71 static final String[] EVENT_PROJECTION = new String[] { 72 Instances.ALL_DAY, 73 Instances.BEGIN, 74 Instances.END, 75 Instances.TITLE, 76 Instances.EVENT_LOCATION, 77 Instances.EVENT_ID, 78 }; 79 80 static final int INDEX_ALL_DAY = 0; 81 static final int INDEX_BEGIN = 1; 82 static final int INDEX_END = 2; 83 static final int INDEX_TITLE = 3; 84 static final int INDEX_EVENT_LOCATION = 4; 85 static final int INDEX_EVENT_ID = 5; 86 87 private static final long SEARCH_DURATION = DateUtils.WEEK_IN_MILLIS; 88 89 // If no next-update calculated, or bad trigger time in past, schedule 90 // update about six hours from now. 91 private static final long UPDATE_NO_EVENTS = DateUtils.HOUR_IN_MILLIS * 6; 92 93 @Override 94 public RemoteViewsFactory onGetViewFactory(Intent intent) { 95 return new CalendarFactory(getApplicationContext(), intent); 96 } 97 98 protected static class MarkedEvents { 99 100 /** 101 * The row IDs of all events marked for display 102 */ 103 List<Integer> markedIds = new ArrayList<Integer>(10); 104 105 /** 106 * The start time of the first marked event 107 */ 108 long firstTime = -1; 109 110 /** The number of events currently in progress */ 111 int inProgressCount = 0; // Number of events with same start time as the primary evt. 112 113 /** The start time of the next upcoming event */ 114 long primaryTime = -1; 115 116 /** 117 * The number of events that share the same start time as the next 118 * upcoming event 119 */ 120 int primaryCount = 0; // Number of events with same start time as the secondary evt. 121 122 /** The start time of the next next upcoming event */ 123 long secondaryTime = 1; 124 125 /** 126 * The number of events that share the same start time as the next next 127 * upcoming event. 128 */ 129 int secondaryCount = 0; 130 } 131 132 protected static class CalendarFactory implements RemoteViewsService.RemoteViewsFactory { 133 134 private static final String TAG = CalendarFactory.class.getSimpleName(); 135 136 private static final boolean LOGD = false; 137 138 private final BroadcastReceiver mIntentReceiver = new BroadcastReceiver() { 139 @Override 140 public void onReceive(Context context, Intent intent) { 141 String action = intent.getAction(); 142 if (action.equals(CalendarAppWidgetProvider.ACTION_CALENDAR_APPWIDGET_UPDATE) 143 || action.equals(Intent.ACTION_TIMEZONE_CHANGED) 144 || action.equals(Intent.ACTION_TIME_CHANGED) 145 || action.equals(Intent.ACTION_DATE_CHANGED) 146 || (action.equals(Intent.ACTION_PROVIDER_CHANGED) 147 && intent.getData().equals(Calendar.CONTENT_URI))) { 148 loadData(); 149 } 150 } 151 }; 152 153 private final ContentObserver mContentObserver = new ContentObserver(new Handler()) { 154 @Override 155 public boolean deliverSelfNotifications() { 156 return true; 157 } 158 159 @Override 160 public void onChange(boolean selfChange) { 161 loadData(); 162 } 163 }; 164 165 private final int mAppWidgetId; 166 167 private Context mContext; 168 169 private CalendarAppWidgetModel mModel; 170 171 private Cursor mCursor; 172 173 protected CalendarFactory(Context context, Intent intent) { 174 mContext = context; 175 mAppWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 176 AppWidgetManager.INVALID_APPWIDGET_ID); 177 } 178 179 @Override 180 public void onCreate() { 181 loadData(); 182 IntentFilter filter = new IntentFilter(); 183 filter.addAction(CalendarAppWidgetProvider.ACTION_CALENDAR_APPWIDGET_UPDATE); 184 filter.addAction(Intent.ACTION_TIME_CHANGED); 185 filter.addAction(Intent.ACTION_DATE_CHANGED); 186 filter.addAction(Intent.ACTION_TIMEZONE_CHANGED); 187 filter.addAction(Intent.ACTION_PROVIDER_CHANGED); 188 mContext.registerReceiver(mIntentReceiver, filter); 189 190 mContext.getContentResolver().registerContentObserver( 191 Events.CONTENT_URI, true, mContentObserver); 192 } 193 194 @Override 195 public void onDestroy() { 196 mCursor.close(); 197 mContext.unregisterReceiver(mIntentReceiver); 198 mContext.getContentResolver().unregisterContentObserver(mContentObserver); 199 } 200 201 202 @Override 203 public RemoteViews getLoadingView() { 204 RemoteViews views = new RemoteViews(mContext.getPackageName(), 205 R.layout.appwidget_loading); 206 return views; 207 } 208 209 @Override 210 public RemoteViews getViewAt(int position) { 211 // we use getCount here so that it doesn't return null when empty 212 if (position < 0 || position >= getCount()) { 213 return null; 214 } 215 216 if (mModel.eventInfos.length > 0) { 217 RemoteViews views = new RemoteViews(mContext.getPackageName(), 218 R.layout.appwidget_row); 219 220 EventInfo e = mModel.eventInfos[position]; 221 222 updateTextView(views, R.id.when, e.visibWhen, e.when); 223 updateTextView(views, R.id.where, e.visibWhere, e.where); 224 updateTextView(views, R.id.title, e.visibTitle, e.title); 225 226 PendingIntent launchIntent = 227 CalendarAppWidgetProvider.getLaunchPendingIntent( 228 mContext, e.start); 229 views.setOnClickPendingIntent(R.id.appwidget_row, launchIntent); 230 return views; 231 } else { 232 RemoteViews views = new RemoteViews(mContext.getPackageName(), 233 R.layout.appwidget_no_events); 234 PendingIntent launchIntent = 235 CalendarAppWidgetProvider.getLaunchPendingIntent( 236 mContext, 0); 237 views.setOnClickPendingIntent(R.id.appwidget_no_events, launchIntent); 238 return views; 239 } 240 } 241 242 @Override 243 public int getViewTypeCount() { 244 return 3; 245 } 246 247 @Override 248 public int getCount() { 249 // if there are no events, we still return 1 to represent the "no 250 // events" view 251 return Math.max(1, mModel.eventInfos.length); 252 } 253 254 @Override 255 public long getItemId(int position) { 256 return position; 257 } 258 259 @Override 260 public boolean hasStableIds() { 261 return true; 262 } 263 264 private void loadData() { 265 long now = System.currentTimeMillis(); 266 if (LOGD) Log.d(TAG, "Querying for widget events..."); 267 if (mCursor != null) { 268 mCursor.close(); 269 } 270 271 mCursor = getUpcomingInstancesCursor( 272 mContext.getContentResolver(), SEARCH_DURATION, now); 273 MarkedEvents markedEvents = buildMarkedEvents(mCursor, now); 274 mModel = buildAppWidgetModel(mContext, mCursor, markedEvents, now); 275 long triggerTime = calculateUpdateTime(mCursor, markedEvents); 276 // Schedule an alarm to wake ourselves up for the next update. We also cancel 277 // all existing wake-ups because PendingIntents don't match against extras. 278 279 // If no next-update calculated, or bad trigger time in past, schedule 280 // update about six hours from now. 281 if (triggerTime == -1 || triggerTime < now) { 282 if (LOGD) Log.w(TAG, "Encountered bad trigger time " + 283 formatDebugTime(triggerTime, now)); 284 triggerTime = now + UPDATE_NO_EVENTS; 285 } 286 287 AlarmManager am = (AlarmManager) mContext.getSystemService(Context.ALARM_SERVICE); 288 PendingIntent pendingUpdate = CalendarAppWidgetProvider.getUpdateIntent(mContext); 289 290 am.cancel(pendingUpdate); 291 am.set(AlarmManager.RTC, triggerTime, pendingUpdate); 292 if (LOGD) Log.d(TAG, "Scheduled next update at " + formatDebugTime(triggerTime, now)); 293 } 294 295 /** 296 * Query across all calendars for upcoming event instances from now until 297 * some time in the future. 298 * 299 * Widen the time range that we query by one day on each end so that we can 300 * catch all-day events. All-day events are stored starting at midnight in 301 * UTC but should be included in the list of events starting at midnight 302 * local time. This may fetch more events than we actually want, so we 303 * filter them out later. 304 * 305 * @param resolver {@link ContentResolver} to use when querying 306 * {@link Instances#CONTENT_URI}. 307 * @param searchDuration Distance into the future to look for event 308 * instances, in milliseconds. 309 * @param now Current system time to use for this update, possibly from 310 * {@link System#currentTimeMillis()}. 311 */ 312 private Cursor getUpcomingInstancesCursor(ContentResolver resolver, 313 long searchDuration, long now) { 314 // Search for events from now until some time in the future 315 316 // Add a day on either side to catch all-day events 317 long begin = now - DateUtils.DAY_IN_MILLIS; 318 long end = now + searchDuration + DateUtils.DAY_IN_MILLIS; 319 320 Uri uri = Uri.withAppendedPath(Instances.CONTENT_URI, 321 String.format("%d/%d", begin, end)); 322 323 Cursor cursor = resolver.query(uri, EVENT_PROJECTION, 324 EVENT_SELECTION, null, EVENT_SORT_ORDER); 325 326 // Start managing the cursor ourselves 327 MatrixCursor matrixCursor = Utils.matrixCursorFromCursor(cursor); 328 cursor.close(); 329 330 return matrixCursor; 331 } 332 333 /** 334 * Walk the given instances cursor and build a list of marked events to be 335 * used when updating the widget. This structure is also used to check if 336 * updates are needed. 337 * 338 * @param cursor Valid cursor across {@link Instances#CONTENT_URI}. 339 * @param watchEventIds Specific events to watch for, setting 340 * {@link MarkedEvents#watchFound} if found during marking. 341 * @param now Current system time to use for this update, possibly from 342 * {@link System#currentTimeMillis()} 343 */ 344 @VisibleForTesting 345 protected static MarkedEvents buildMarkedEvents(Cursor cursor, long now) { 346 MarkedEvents events = new MarkedEvents(); 347 final Time recycle = new Time(); 348 349 cursor.moveToPosition(-1); 350 while (cursor.moveToNext()) { 351 int row = cursor.getPosition(); 352 long eventId = cursor.getLong(INDEX_EVENT_ID); 353 long start = cursor.getLong(INDEX_BEGIN); 354 long end = cursor.getLong(INDEX_END); 355 356 boolean allDay = cursor.getInt(INDEX_ALL_DAY) != 0; 357 358 if (LOGD) { 359 Log.d(TAG, "Row #" + row + " allDay:" + allDay + " start:" + start 360 + " end:" + end + " eventId:" + eventId); 361 } 362 363 // Adjust all-day times into local timezone 364 if (allDay) { 365 start = convertUtcToLocal(recycle, start); 366 end = convertUtcToLocal(recycle, end); 367 } 368 369 if (end < now) { 370 // we might get some extra events when querying, in order to 371 // deal with all-day events 372 continue; 373 } 374 375 boolean inProgress = now < end && now > start; 376 377 // Skip events that have already passed their flip times 378 long eventFlip = getEventFlip(cursor, start, end, allDay); 379 if (LOGD) Log.d(TAG, "Calculated flip time " + formatDebugTime(eventFlip, now)); 380 if (eventFlip < now) { 381 continue; 382 } 383 384// /* Scan through the events with the following logic: 385// * Rule #1 Show A) all the events that are in progress including 386// * all day events and B) the next upcoming event and any events 387// * with the same start time. 388// * 389// * Rule #2 If there are no events in progress, show A) the next 390// * upcoming event and B) any events with the same start time. 391// * 392// * Rule #3 If no events start at the same time at A in rule 2, 393// * show A) the next upcoming event and B) the following upcoming 394// * event + any events with the same start time. 395// */ 396// if (inProgress) { 397// // events for part A of Rule #1 398// events.markedIds.add(row); 399// events.inProgressCount++; 400// if (events.firstTime == -1) { 401// events.firstTime = start; 402// } 403// } else { 404// if (events.primaryCount == 0) { 405// // first upcoming event 406// events.markedIds.add(row); 407// events.primaryTime = start; 408// events.primaryCount++; 409// if (events.firstTime == -1) { 410// events.firstTime = start; 411// } 412// } else if (events.primaryTime == start) { 413// // any events with same start time as first upcoming event 414// events.markedIds.add(row); 415// events.primaryCount++; 416// } else if (events.markedIds.size() == 1) { 417// // only one upcoming event, so we take the next upcoming 418// events.markedIds.add(row); 419// events.secondaryTime = start; 420// events.secondaryCount++; 421// } else if (events.secondaryCount > 0 422// && events.secondaryTime == start) { 423// // any events with same start time as next upcoming 424// events.markedIds.add(row); 425// events.secondaryCount++; 426// } else { 427// // looks like we're done 428// break; 429// } 430// } 431 432 events.markedIds.add(row); 433 } 434 return events; 435 } 436 437 @VisibleForTesting 438 protected static CalendarAppWidgetModel buildAppWidgetModel( 439 Context context, Cursor cursor, MarkedEvents events, long currentTime) { 440 int eventCount = events.markedIds.size(); 441 CalendarAppWidgetModel model = new CalendarAppWidgetModel(eventCount); 442 Time time = new Time(); 443 time.set(currentTime); 444 time.monthDay++; 445 time.hour = 0; 446 time.minute = 0; 447 time.second = 0; 448 long startOfNextDay = time.normalize(true); 449 450 time.set(currentTime); 451 452 // Calendar header 453 String dayOfWeek = DateUtils.getDayOfWeekString( 454 time.weekDay + 1, DateUtils.LENGTH_MEDIUM).toUpperCase(); 455 456 model.dayOfWeek = dayOfWeek; 457 model.dayOfMonth = Integer.toString(time.monthDay); 458 459 int i = 0; 460 for (Integer id : events.markedIds) { 461 populateEvent(context, cursor, id, model, time, i, true, 462 startOfNextDay, currentTime); 463 i++; 464 } 465 466 return model; 467 } 468 469 /** 470 * Figure out the next time we should push widget updates, usually the time 471 * calculated by {@link #getEventFlip(Cursor, long, long, boolean)}. 472 * 473 * @param cursor Valid cursor on {@link Instances#CONTENT_URI} 474 * @param events {@link MarkedEvents} parsed from the cursor 475 */ 476 private long calculateUpdateTime(Cursor cursor, MarkedEvents events) { 477 long result = -1; 478 if (!events.markedIds.isEmpty()) { 479 cursor.moveToPosition(events.markedIds.get(0)); 480 long start = cursor.getLong(INDEX_BEGIN); 481 long end = cursor.getLong(INDEX_END); 482 boolean allDay = cursor.getInt(INDEX_ALL_DAY) != 0; 483 484 // Adjust all-day times into local timezone 485 if (allDay) { 486 final Time recycle = new Time(); 487 start = convertUtcToLocal(recycle, start); 488 end = convertUtcToLocal(recycle, end); 489 } 490 491 result = getEventFlip(cursor, start, end, allDay); 492 493 // Make sure an update happens at midnight or earlier 494 long midnight = getNextMidnightTimeMillis(); 495 result = Math.min(midnight, result); 496 } 497 return result; 498 } 499 500 private static long getNextMidnightTimeMillis() { 501 Time time = new Time(); 502 time.setToNow(); 503 time.monthDay++; 504 time.hour = 0; 505 time.minute = 0; 506 time.second = 0; 507 long midnight = time.normalize(true); 508 return midnight; 509 } 510 511 /** 512 * Format given time for debugging output. 513 * 514 * @param unixTime Target time to report. 515 * @param now Current system time from {@link System#currentTimeMillis()} 516 * for calculating time difference. 517 */ 518 static private String formatDebugTime(long unixTime, long now) { 519 Time time = new Time(); 520 time.set(unixTime); 521 522 long delta = unixTime - now; 523 if (delta > DateUtils.MINUTE_IN_MILLIS) { 524 delta /= DateUtils.MINUTE_IN_MILLIS; 525 return String.format("[%d] %s (%+d mins)", unixTime, 526 time.format("%H:%M:%S"), delta); 527 } else { 528 delta /= DateUtils.SECOND_IN_MILLIS; 529 return String.format("[%d] %s (%+d secs)", unixTime, 530 time.format("%H:%M:%S"), delta); 531 } 532 } 533 534 /** 535 * Convert given UTC time into current local time. 536 * 537 * @param recycle Time object to recycle, otherwise null. 538 * @param utcTime Time to convert, in UTC. 539 */ 540 static private long convertUtcToLocal(Time recycle, long utcTime) { 541 if (recycle == null) { 542 recycle = new Time(); 543 } 544 recycle.timezone = Time.TIMEZONE_UTC; 545 recycle.set(utcTime); 546 recycle.timezone = TimeZone.getDefault().getID(); 547 return recycle.normalize(true); 548 } 549 550 /** 551 * Calculate flipping point for the given event; when we should hide this 552 * event and show the next one. This is defined as the end time of the 553 * event. 554 * 555 * @param start Event start time in local timezone. 556 * @param end Event end time in local timezone. 557 */ 558 static private long getEventFlip(Cursor cursor, long start, long end, boolean allDay) { 559 return end; 560 } 561 562 static void updateTextView(RemoteViews views, int id, int visibility, String string) { 563 views.setViewVisibility(id, visibility); 564 if (visibility == View.VISIBLE) { 565 views.setTextViewText(id, string); 566 } 567 } 568 569 /** 570 * Pulls the information for a single event from the cursor and populates 571 * the corresponding model object with the data. 572 * 573 * @param context a Context to use for accessing resources 574 * @param cursor the cursor to retrieve the data from 575 * @param rowId the ID of the row to retrieve 576 * @param model the model object to populate 577 * @param recycle a Time instance to recycle 578 * @param eventIndex which event index in the model to populate 579 * @param showTitleLocation whether or not to show the title and location 580 * @param startOfNextDay the beginning of the next day 581 * @param currentTime the current time 582 */ 583 static private void populateEvent(Context context, Cursor cursor, int rowId, 584 CalendarAppWidgetModel model, Time recycle, int eventIndex, 585 boolean showTitleLocation, long startOfNextDay, long currentTime) { 586 cursor.moveToPosition(rowId); 587 588 // When 589 boolean allDay = cursor.getInt(INDEX_ALL_DAY) != 0; 590 long start = cursor.getLong(INDEX_BEGIN); 591 long end = cursor.getLong(INDEX_END); 592 if (allDay) { 593 start = convertUtcToLocal(recycle, start); 594 end = convertUtcToLocal(recycle, end); 595 } 596 597 boolean eventIsInProgress = start <= currentTime && end > currentTime; 598 boolean eventIsToday = start < startOfNextDay; 599 boolean eventIsTomorrow = !eventIsToday && !eventIsInProgress 600 && (start < (startOfNextDay + DateUtils.DAY_IN_MILLIS)); 601 602 // Compute a human-readable string for the start time of the event 603 String whenString; 604 if (eventIsInProgress && allDay) { 605 // All day events for the current day display as just "Today" 606 whenString = context.getString(R.string.today); 607 } else if (eventIsTomorrow && allDay) { 608 // All day events for the next day display as just "Tomorrow" 609 whenString = context.getString(R.string.tomorrow); 610 } else { 611 int flags = DateUtils.FORMAT_ABBREV_ALL; 612 if (allDay) { 613 flags |= DateUtils.FORMAT_UTC; 614 } else { 615 flags |= DateUtils.FORMAT_SHOW_TIME; 616 if (DateFormat.is24HourFormat(context)) { 617 flags |= DateUtils.FORMAT_24HOUR; 618 } 619 } 620 // Show day of the week if not today or tomorrow 621 if (!eventIsTomorrow && !eventIsToday) { 622 flags |= DateUtils.FORMAT_SHOW_WEEKDAY; 623 } 624 whenString = DateUtils.formatDateRange(context, start, start, flags); 625 // TODO better i18n formatting 626 if (eventIsTomorrow) { 627 whenString += (", "); 628 whenString += context.getString(R.string.tomorrow); 629 } else if (eventIsInProgress) { 630 whenString += " ("; 631 whenString += context.getString(R.string.in_progress); 632 whenString += ")"; 633 } 634 } 635 636 model.eventInfos[eventIndex].start = start; 637 model.eventInfos[eventIndex].when = whenString; 638 model.eventInfos[eventIndex].visibWhen = View.VISIBLE; 639 640 if (showTitleLocation) { 641 // What 642 String titleString = cursor.getString(INDEX_TITLE); 643 if (TextUtils.isEmpty(titleString)) { 644 titleString = context.getString(R.string.no_title_label); 645 } 646 model.eventInfos[eventIndex].title = titleString; 647 model.eventInfos[eventIndex].visibTitle = View.VISIBLE; 648 649 // Where 650 String whereString = cursor.getString(INDEX_EVENT_LOCATION); 651 if (!TextUtils.isEmpty(whereString)) { 652 model.eventInfos[eventIndex].visibWhere = View.VISIBLE; 653 model.eventInfos[eventIndex].where = whereString; 654 } else { 655 model.eventInfos[eventIndex].visibWhere = View.GONE; 656 } 657 if (LOGD) Log.d(TAG, " Title:" + titleString + " Where:" + whereString); 658 } 659 } 660 } 661} 662