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