CalendarAppWidgetService.java revision 6bcafcf4f28c2d1053547694bd60b3dd9c7fbaa1
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 com.android.calendar.R; 20import com.android.calendar.Utils; 21import com.android.calendar.widget.CalendarAppWidgetModel.DayInfo; 22import com.android.calendar.widget.CalendarAppWidgetModel.EventInfo; 23import com.android.calendar.widget.CalendarAppWidgetModel.RowInfo; 24 25import java.util.concurrent.ExecutorService; 26import java.util.concurrent.Executors; 27import java.util.concurrent.atomic.AtomicInteger; 28 29import android.app.AlarmManager; 30import android.app.PendingIntent; 31import android.appwidget.AppWidgetManager; 32import android.content.BroadcastReceiver; 33import android.content.Context; 34import android.content.CursorLoader; 35import android.content.Intent; 36import android.content.Loader; 37import android.content.res.Resources; 38import android.database.Cursor; 39import android.database.MatrixCursor; 40import android.net.Uri; 41import android.os.Handler; 42import android.provider.CalendarContract.Attendees; 43import android.provider.CalendarContract.Calendars; 44import android.provider.CalendarContract.Instances; 45import android.text.format.DateUtils; 46import android.text.format.Time; 47import android.util.Log; 48import android.view.View; 49import android.widget.RemoteViews; 50import android.widget.RemoteViewsService; 51 52 53public class CalendarAppWidgetService extends RemoteViewsService { 54 private static final String TAG = "CalendarWidget"; 55 56 static final int EVENT_MIN_COUNT = 20; 57 static final int EVENT_MAX_COUNT = 50; 58 // Minimum delay between queries on the database for widget updates in ms 59 static final int WIDGET_UPDATE_THROTTLE = 500; 60 61 private static final String EVENT_SORT_ORDER = Instances.START_DAY + " ASC, " 62 + Instances.START_MINUTE + " ASC, " + Instances.END_DAY + " ASC, " 63 + Instances.END_MINUTE + " ASC LIMIT " + EVENT_MAX_COUNT; 64 65 private static final String EVENT_SELECTION = Calendars.VISIBLE + "=1"; 66 private static final String EVENT_SELECTION_HIDE_DECLINED = Calendars.VISIBLE + "=1 AND " 67 + Instances.SELF_ATTENDEE_STATUS + "!=" + Attendees.ATTENDEE_STATUS_DECLINED; 68 69 static final String[] EVENT_PROJECTION = new String[] { 70 Instances.ALL_DAY, 71 Instances.BEGIN, 72 Instances.END, 73 Instances.TITLE, 74 Instances.EVENT_LOCATION, 75 Instances.EVENT_ID, 76 Instances.START_DAY, 77 Instances.END_DAY, 78 Instances.DISPLAY_COLOR, 79 Instances.SELF_ATTENDEE_STATUS, 80 }; 81 82 static final int INDEX_ALL_DAY = 0; 83 static final int INDEX_BEGIN = 1; 84 static final int INDEX_END = 2; 85 static final int INDEX_TITLE = 3; 86 static final int INDEX_EVENT_LOCATION = 4; 87 static final int INDEX_EVENT_ID = 5; 88 static final int INDEX_START_DAY = 6; 89 static final int INDEX_END_DAY = 7; 90 static final int INDEX_COLOR = 8; 91 static final int INDEX_SELF_ATTENDEE_STATUS = 9; 92 93 static final int MAX_DAYS = 7; 94 95 private static final long SEARCH_DURATION = MAX_DAYS * DateUtils.DAY_IN_MILLIS; 96 97 /** 98 * Update interval used when no next-update calculated, or bad trigger time in past. 99 * Unit: milliseconds. 100 */ 101 private static final long UPDATE_TIME_NO_EVENTS = DateUtils.HOUR_IN_MILLIS * 6; 102 103 @Override 104 public RemoteViewsFactory onGetViewFactory(Intent intent) { 105 return new CalendarFactory(getApplicationContext(), intent); 106 } 107 108 public static class CalendarFactory extends BroadcastReceiver implements 109 RemoteViewsService.RemoteViewsFactory, Loader.OnLoadCompleteListener<Cursor> { 110 private static final boolean LOGD = false; 111 112 // Suppress unnecessary logging about update time. Need to be static as this object is 113 // re-instanciated frequently. 114 // TODO: It seems loadData() is called via onCreate() four times, which should mean 115 // unnecessary CalendarFactory object is created and dropped. It is not efficient. 116 private static long sLastUpdateTime = UPDATE_TIME_NO_EVENTS; 117 118 private Context mContext; 119 private Resources mResources; 120 private static CalendarAppWidgetModel mModel; 121 private static Cursor mCursor; 122 private static volatile Integer mLock = new Integer(0); 123 private int mLastLock; 124 private CursorLoader mLoader; 125 private final Handler mHandler = new Handler(); 126 private static final AtomicInteger currentVersion = new AtomicInteger(0); 127 private final ExecutorService executor = Executors.newSingleThreadExecutor(); 128 private int mAppWidgetId; 129 private int mDeclinedColor; 130 private int mStandardColor; 131 private int mAllDayColor; 132 133 private final Runnable mTimezoneChanged = new Runnable() { 134 @Override 135 public void run() { 136 if (mLoader != null) { 137 mLoader.forceLoad(); 138 } 139 } 140 }; 141 142 private Runnable createUpdateLoaderRunnable(final String selection, 143 final PendingResult result, final int version) { 144 return new Runnable() { 145 @Override 146 public void run() { 147 // If there is a newer load request in the queue, skip loading. 148 if (mLoader != null && version >= currentVersion.get()) { 149 Uri uri = createLoaderUri(); 150 mLoader.setUri(uri); 151 mLoader.setSelection(selection); 152 synchronized (mLock) { 153 mLastLock = ++mLock; 154 } 155 mLoader.forceLoad(); 156 } 157 result.finish(); 158 } 159 }; 160 } 161 162 protected CalendarFactory(Context context, Intent intent) { 163 mContext = context; 164 mResources = context.getResources(); 165 mAppWidgetId = intent.getIntExtra( 166 AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); 167 168 mDeclinedColor = mResources.getColor(R.color.appwidget_item_declined_color); 169 mStandardColor = mResources.getColor(R.color.appwidget_item_standard_color); 170 mAllDayColor = mResources.getColor(R.color.appwidget_item_allday_color); 171 } 172 173 public CalendarFactory() { 174 // This is being created as part of onReceive 175 176 } 177 178 @Override 179 public void onCreate() { 180 String selection = queryForSelection(); 181 initLoader(selection); 182 } 183 184 @Override 185 public void onDataSetChanged() { 186 } 187 188 @Override 189 public void onDestroy() { 190 if (mCursor != null) { 191 mCursor.close(); 192 } 193 if (mLoader != null) { 194 mLoader.reset(); 195 } 196 } 197 198 @Override 199 public RemoteViews getLoadingView() { 200 RemoteViews views = new RemoteViews(mContext.getPackageName(), 201 R.layout.appwidget_loading); 202 return views; 203 } 204 205 @Override 206 public RemoteViews getViewAt(int position) { 207 // we use getCount here so that it doesn't return null when empty 208 if (position < 0 || position >= getCount()) { 209 return null; 210 } 211 212 if (mModel == null) { 213 RemoteViews views = new RemoteViews(mContext.getPackageName(), 214 R.layout.appwidget_loading); 215 final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0, 216 0, 0, false); 217 views.setOnClickFillInIntent(R.id.appwidget_loading, intent); 218 return views; 219 220 } 221 if (mModel.mEventInfos.isEmpty() || mModel.mRowInfos.isEmpty()) { 222 RemoteViews views = new RemoteViews(mContext.getPackageName(), 223 R.layout.appwidget_no_events); 224 final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0, 225 0, 0, false); 226 views.setOnClickFillInIntent(R.id.appwidget_no_events, intent); 227 return views; 228 } 229 230 RowInfo rowInfo = mModel.mRowInfos.get(position); 231 if (rowInfo.mType == RowInfo.TYPE_DAY) { 232 RemoteViews views = new RemoteViews(mContext.getPackageName(), 233 R.layout.appwidget_day); 234 DayInfo dayInfo = mModel.mDayInfos.get(rowInfo.mIndex); 235 updateTextView(views, R.id.date, View.VISIBLE, dayInfo.mDayLabel); 236 return views; 237 } else { 238 RemoteViews views; 239 final EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex); 240 if (eventInfo.allDay) { 241 views = new RemoteViews(mContext.getPackageName(), 242 R.layout.widget_all_day_item); 243 } else { 244 views = new RemoteViews(mContext.getPackageName(), R.layout.widget_item); 245 } 246 int displayColor = Utils.getDisplayColorFromColor(eventInfo.color); 247 248 final long now = System.currentTimeMillis(); 249 if (!eventInfo.allDay && eventInfo.start <= now && now <= eventInfo.end) { 250 views.setInt(R.id.widget_row, "setBackgroundResource", 251 R.drawable.agenda_item_bg_secondary); 252 } else { 253 views.setInt(R.id.widget_row, "setBackgroundResource", 254 R.drawable.agenda_item_bg_primary); 255 } 256 257 if (!eventInfo.allDay) { 258 updateTextView(views, R.id.when, eventInfo.visibWhen, eventInfo.when); 259 updateTextView(views, R.id.where, eventInfo.visibWhere, eventInfo.where); 260 } 261 updateTextView(views, R.id.title, eventInfo.visibTitle, eventInfo.title); 262 263 views.setViewVisibility(R.id.agenda_item_color, View.VISIBLE); 264 265 int selfAttendeeStatus = eventInfo.selfAttendeeStatus; 266 if (eventInfo.allDay) { 267 if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) { 268 views.setInt(R.id.agenda_item_color, "setImageResource", 269 R.drawable.widget_chip_not_responded_bg); 270 views.setInt(R.id.title, "setTextColor", displayColor); 271 } else { 272 views.setInt(R.id.agenda_item_color, "setImageResource", 273 R.drawable.widget_chip_responded_bg); 274 views.setInt(R.id.title, "setTextColor", mAllDayColor); 275 } 276 if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) { 277 // 40% opacity 278 views.setInt(R.id.agenda_item_color, "setColorFilter", 279 Utils.getDeclinedColorFromColor(displayColor)); 280 } else { 281 views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor); 282 } 283 } else if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) { 284 views.setInt(R.id.title, "setTextColor", mDeclinedColor); 285 views.setInt(R.id.when, "setTextColor", mDeclinedColor); 286 views.setInt(R.id.where, "setTextColor", mDeclinedColor); 287 // views.setInt(R.id.agenda_item_color, "setDrawStyle", 288 // ColorChipView.DRAW_CROSS_HATCHED); 289 views.setInt(R.id.agenda_item_color, "setImageResource", 290 R.drawable.widget_chip_responded_bg); 291 // 40% opacity 292 views.setInt(R.id.agenda_item_color, "setColorFilter", 293 Utils.getDeclinedColorFromColor(displayColor)); 294 } else { 295 views.setInt(R.id.title, "setTextColor", mStandardColor); 296 views.setInt(R.id.when, "setTextColor", mStandardColor); 297 views.setInt(R.id.where, "setTextColor", mStandardColor); 298 if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) { 299 views.setInt(R.id.agenda_item_color, "setImageResource", 300 R.drawable.widget_chip_not_responded_bg); 301 } else { 302 views.setInt(R.id.agenda_item_color, "setImageResource", 303 R.drawable.widget_chip_responded_bg); 304 } 305 views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor); 306 } 307 308 long start = eventInfo.start; 309 long end = eventInfo.end; 310 // An element in ListView. 311 if (eventInfo.allDay) { 312 String tz = Utils.getTimeZone(mContext, null); 313 Time recycle = new Time(); 314 start = Utils.convertAlldayLocalToUTC(recycle, start, tz); 315 end = Utils.convertAlldayLocalToUTC(recycle, end, tz); 316 } 317 final Intent fillInIntent = CalendarAppWidgetProvider.getLaunchFillInIntent( 318 mContext, eventInfo.id, start, end, eventInfo.allDay); 319 views.setOnClickFillInIntent(R.id.widget_row, fillInIntent); 320 return views; 321 } 322 } 323 324 @Override 325 public int getViewTypeCount() { 326 return 4; 327 } 328 329 @Override 330 public int getCount() { 331 // if there are no events, we still return 1 to represent the "no 332 // events" view 333 if (mModel == null) { 334 return 1; 335 } 336 return Math.max(1, mModel.mRowInfos.size()); 337 } 338 339 @Override 340 public long getItemId(int position) { 341 if (mModel == null || mModel.mRowInfos.isEmpty() || position >= getCount()) { 342 return 0; 343 } 344 RowInfo rowInfo = mModel.mRowInfos.get(position); 345 if (rowInfo.mType == RowInfo.TYPE_DAY) { 346 return rowInfo.mIndex; 347 } 348 EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex); 349 long prime = 31; 350 long result = 1; 351 result = prime * result + (int) (eventInfo.id ^ (eventInfo.id >>> 32)); 352 result = prime * result + (int) (eventInfo.start ^ (eventInfo.start >>> 32)); 353 return result; 354 } 355 356 @Override 357 public boolean hasStableIds() { 358 return true; 359 } 360 361 /** 362 * Query across all calendars for upcoming event instances from now 363 * until some time in the future. Widen the time range that we query by 364 * one day on each end so that we can catch all-day events. All-day 365 * events are stored starting at midnight in UTC but should be included 366 * in the list of events starting at midnight local time. This may fetch 367 * more events than we actually want, so we filter them out later. 368 * 369 * @param selection The selection string for the loader to filter the query with. 370 */ 371 public void initLoader(String selection) { 372 if (LOGD) 373 Log.d(TAG, "Querying for widget events..."); 374 375 // Search for events from now until some time in the future 376 Uri uri = createLoaderUri(); 377 mLoader = new CursorLoader(mContext, uri, EVENT_PROJECTION, selection, null, 378 EVENT_SORT_ORDER); 379 mLoader.setUpdateThrottle(WIDGET_UPDATE_THROTTLE); 380 synchronized (mLock) { 381 mLastLock = ++mLock; 382 } 383 mLoader.registerListener(mAppWidgetId, this); 384 mLoader.startLoading(); 385 386 } 387 388 /** 389 * This gets the selection string for the loader. This ends up doing a query in the 390 * shared preferences. 391 */ 392 private String queryForSelection() { 393 return Utils.getHideDeclinedEvents(mContext) ? EVENT_SELECTION_HIDE_DECLINED 394 : EVENT_SELECTION; 395 } 396 397 /** 398 * @return The uri for the loader 399 */ 400 private Uri createLoaderUri() { 401 long now = System.currentTimeMillis(); 402 // Add a day on either side to catch all-day events 403 long begin = now - DateUtils.DAY_IN_MILLIS; 404 long end = now + SEARCH_DURATION + DateUtils.DAY_IN_MILLIS; 405 406 Uri uri = Uri.withAppendedPath(Instances.CONTENT_URI, Long.toString(begin) + "/" + end); 407 return uri; 408 } 409 410 /* @VisibleForTesting */ 411 protected static CalendarAppWidgetModel buildAppWidgetModel( 412 Context context, Cursor cursor, String timeZone) { 413 CalendarAppWidgetModel model = new CalendarAppWidgetModel(context, timeZone); 414 model.buildFromCursor(cursor, timeZone); 415 return model; 416 } 417 418 /** 419 * Calculates and returns the next time we should push widget updates. 420 */ 421 private long calculateUpdateTime(CalendarAppWidgetModel model, long now, String timeZone) { 422 // Make sure an update happens at midnight or earlier 423 long minUpdateTime = getNextMidnightTimeMillis(timeZone); 424 for (EventInfo event : model.mEventInfos) { 425 final long start; 426 final long end; 427 start = event.start; 428 end = event.end; 429 430 // We want to update widget when we enter/exit time range of an event. 431 if (now < start) { 432 minUpdateTime = Math.min(minUpdateTime, start); 433 } else if (now < end) { 434 minUpdateTime = Math.min(minUpdateTime, end); 435 } 436 } 437 return minUpdateTime; 438 } 439 440 private static long getNextMidnightTimeMillis(String timezone) { 441 Time time = new Time(); 442 time.setToNow(); 443 time.monthDay++; 444 time.hour = 0; 445 time.minute = 0; 446 time.second = 0; 447 long midnightDeviceTz = time.normalize(true); 448 449 time.timezone = timezone; 450 time.setToNow(); 451 time.monthDay++; 452 time.hour = 0; 453 time.minute = 0; 454 time.second = 0; 455 long midnightHomeTz = time.normalize(true); 456 457 return Math.min(midnightDeviceTz, midnightHomeTz); 458 } 459 460 static void updateTextView(RemoteViews views, int id, int visibility, String string) { 461 views.setViewVisibility(id, visibility); 462 if (visibility == View.VISIBLE) { 463 views.setTextViewText(id, string); 464 } 465 } 466 467 /* 468 * (non-Javadoc) 469 * @see 470 * android.content.Loader.OnLoadCompleteListener#onLoadComplete(android 471 * .content.Loader, java.lang.Object) 472 */ 473 @Override 474 public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) { 475 if (cursor == null) { 476 return; 477 } 478 // If a newer update has happened since we started clean up and 479 // return 480 synchronized (mLock) { 481 if (mLastLock != mLock) { 482 cursor.close(); 483 return; 484 } 485 // Copy it to a local static cursor. 486 MatrixCursor matrixCursor = Utils.matrixCursorFromCursor(cursor); 487 cursor.close(); 488 489 final long now = System.currentTimeMillis(); 490 if (mCursor != null) { 491 mCursor.close(); 492 } 493 mCursor = matrixCursor; 494 String tz = Utils.getTimeZone(mContext, mTimezoneChanged); 495 mModel = buildAppWidgetModel(mContext, mCursor, tz); 496 497 // Schedule an alarm to wake ourselves up for the next update. 498 // We also cancel 499 // all existing wake-ups because PendingIntents don't match 500 // against extras. 501 long triggerTime = calculateUpdateTime(mModel, now, tz); 502 503 // If no next-update calculated, or bad trigger time in past, 504 // schedule 505 // update about six hours from now. 506 if (triggerTime < now) { 507 Log.w(TAG, "Encountered bad trigger time " + formatDebugTime(triggerTime, now)); 508 triggerTime = now + UPDATE_TIME_NO_EVENTS; 509 } 510 511 final AlarmManager alertManager = (AlarmManager) mContext 512 .getSystemService(Context.ALARM_SERVICE); 513 final PendingIntent pendingUpdate = CalendarAppWidgetProvider 514 .getUpdateIntent(mContext); 515 516 alertManager.cancel(pendingUpdate); 517 alertManager.set(AlarmManager.RTC, triggerTime, pendingUpdate); 518 Time time = new Time(Utils.getTimeZone(mContext, null)); 519 time.setToNow(); 520 521 if (time.normalize(true) != sLastUpdateTime) { 522 Time time2 = new Time(Utils.getTimeZone(mContext, null)); 523 time2.set(sLastUpdateTime); 524 time2.normalize(true); 525 if (time.year != time2.year || time.yearDay != time2.yearDay) { 526 final Intent updateIntent = new Intent( 527 Utils.getWidgetUpdateAction(mContext)); 528 mContext.sendBroadcast(updateIntent); 529 } 530 531 sLastUpdateTime = time.toMillis(true); 532 } 533 534 AppWidgetManager widgetManager = AppWidgetManager.getInstance(mContext); 535 if (mAppWidgetId == -1) { 536 int[] ids = widgetManager.getAppWidgetIds(CalendarAppWidgetProvider 537 .getComponentName(mContext)); 538 539 widgetManager.notifyAppWidgetViewDataChanged(ids, R.id.events_list); 540 } else { 541 widgetManager.notifyAppWidgetViewDataChanged(mAppWidgetId, R.id.events_list); 542 } 543 } 544 } 545 546 @Override 547 public void onReceive(Context context, Intent intent) { 548 if (LOGD) 549 Log.d(TAG, "AppWidgetService received an intent. It was " + intent.toString()); 550 mContext = context; 551 552 // We cannot do any queries from the UI thread, so push the 'selection' query 553 // to a background thread. However the implementation of the latter query 554 // (cursor loading) uses CursorLoader which must be initiated from the UI thread, 555 // so there is some convoluted handshaking here. 556 // 557 // Note that as currently implemented, this must run in a single threaded executor 558 // or else the loads may be run out of order. 559 // 560 // TODO: Remove use of mHandler and CursorLoader, and do all the work synchronously 561 // in the background thread. All the handshaking going on here between the UI and 562 // background thread with using goAsync, mHandler, and CursorLoader is confusing. 563 final PendingResult result = goAsync(); 564 executor.submit(new Runnable() { 565 @Override 566 public void run() { 567 // We always complete queryForSelection() even if the load task ends up being 568 // canceled because of a more recent one. Optimizing this to allow 569 // canceling would require keeping track of all the PendingResults 570 // (from goAsync) to abort them. Defer this until it becomes a problem. 571 final String selection = queryForSelection(); 572 573 if (mLoader == null) { 574 mAppWidgetId = -1; 575 mHandler.post(new Runnable() { 576 @Override 577 public void run() { 578 initLoader(selection); 579 result.finish(); 580 } 581 }); 582 } else { 583 mHandler.post(createUpdateLoaderRunnable(selection, result, 584 currentVersion.incrementAndGet())); 585 } 586 } 587 }); 588 } 589 } 590 591 /** 592 * Format given time for debugging output. 593 * 594 * @param unixTime Target time to report. 595 * @param now Current system time from {@link System#currentTimeMillis()} 596 * for calculating time difference. 597 */ 598 static String formatDebugTime(long unixTime, long now) { 599 Time time = new Time(); 600 time.set(unixTime); 601 602 long delta = unixTime - now; 603 if (delta > DateUtils.MINUTE_IN_MILLIS) { 604 delta /= DateUtils.MINUTE_IN_MILLIS; 605 return String.format("[%d] %s (%+d mins)", unixTime, 606 time.format("%H:%M:%S"), delta); 607 } else { 608 delta /= DateUtils.SECOND_IN_MILLIS; 609 return String.format("[%d] %s (%+d secs)", unixTime, 610 time.format("%H:%M:%S"), delta); 611 } 612 } 613} 614