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 onGetViewFactory(Intent intent)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 Object mLock = new Object(); 127 private static volatile int mSerialNum = 0; 128 private int mLastSerialNum = -1; 129 private CursorLoader mLoader; 130 private final Handler mHandler = new Handler(); 131 private static final AtomicInteger currentVersion = new AtomicInteger(0); 132 private final ExecutorService executor = Executors.newSingleThreadExecutor(); 133 private int mAppWidgetId; 134 private int mDeclinedColor; 135 private int mStandardColor; 136 private int mAllDayColor; 137 138 private final Runnable mTimezoneChanged = new Runnable() { 139 @Override 140 public void run() { 141 if (mLoader != null) { 142 mLoader.forceLoad(); 143 } 144 } 145 }; 146 createUpdateLoaderRunnable(final String selection, final PendingResult result, final int version)147 private Runnable createUpdateLoaderRunnable(final String selection, 148 final PendingResult result, final int version) { 149 return new Runnable() { 150 @Override 151 public void run() { 152 // If there is a newer load request in the queue, skip loading. 153 if (mLoader != null && version >= currentVersion.get()) { 154 Uri uri = createLoaderUri(); 155 mLoader.setUri(uri); 156 mLoader.setSelection(selection); 157 synchronized (mLock) { 158 mLastSerialNum = ++mSerialNum; 159 } 160 mLoader.forceLoad(); 161 } 162 result.finish(); 163 } 164 }; 165 } 166 167 protected CalendarFactory(Context context, Intent intent) { 168 mContext = context; 169 mResources = context.getResources(); 170 mAppWidgetId = intent.getIntExtra( 171 AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); 172 173 mDeclinedColor = mResources.getColor(R.color.appwidget_item_declined_color); 174 mStandardColor = mResources.getColor(R.color.appwidget_item_standard_color); 175 mAllDayColor = mResources.getColor(R.color.appwidget_item_allday_color); 176 } 177 178 public CalendarFactory() { 179 // This is being created as part of onReceive 180 181 } 182 183 @Override 184 public void onCreate() { 185 String selection = queryForSelection(); 186 initLoader(selection); 187 } 188 189 @Override 190 public void onDataSetChanged() { 191 } 192 193 @Override 194 public void onDestroy() { 195 if (mLoader != null) { 196 mLoader.reset(); 197 } 198 } 199 200 @Override 201 public RemoteViews getLoadingView() { 202 RemoteViews views = new RemoteViews(mContext.getPackageName(), 203 R.layout.appwidget_loading); 204 return views; 205 } 206 207 @Override 208 public RemoteViews getViewAt(int position) { 209 // we use getCount here so that it doesn't return null when empty 210 if (position < 0 || position >= getCount()) { 211 return null; 212 } 213 214 if (mModel == null) { 215 RemoteViews views = new RemoteViews(mContext.getPackageName(), 216 R.layout.appwidget_loading); 217 final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0, 218 0, 0, false); 219 views.setOnClickFillInIntent(R.id.appwidget_loading, intent); 220 return views; 221 222 } 223 if (mModel.mEventInfos.isEmpty() || mModel.mRowInfos.isEmpty()) { 224 RemoteViews views = new RemoteViews(mContext.getPackageName(), 225 R.layout.appwidget_no_events); 226 final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0, 227 0, 0, false); 228 views.setOnClickFillInIntent(R.id.appwidget_no_events, intent); 229 return views; 230 } 231 232 RowInfo rowInfo = mModel.mRowInfos.get(position); 233 if (rowInfo.mType == RowInfo.TYPE_DAY) { 234 RemoteViews views = new RemoteViews(mContext.getPackageName(), 235 R.layout.appwidget_day); 236 DayInfo dayInfo = mModel.mDayInfos.get(rowInfo.mIndex); 237 updateTextView(views, R.id.date, View.VISIBLE, dayInfo.mDayLabel); 238 return views; 239 } else { 240 RemoteViews views; 241 final EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex); 242 if (eventInfo.allDay) { 243 views = new RemoteViews(mContext.getPackageName(), 244 R.layout.widget_all_day_item); 245 } else { 246 views = new RemoteViews(mContext.getPackageName(), R.layout.widget_item); 247 } 248 int displayColor = Utils.getDisplayColorFromColor(eventInfo.color); 249 250 final long now = System.currentTimeMillis(); 251 if (!eventInfo.allDay && eventInfo.start <= now && now <= eventInfo.end) { 252 views.setInt(R.id.widget_row, "setBackgroundResource", 253 R.drawable.agenda_item_bg_secondary); 254 } else { 255 views.setInt(R.id.widget_row, "setBackgroundResource", 256 R.drawable.agenda_item_bg_primary); 257 } 258 259 if (!eventInfo.allDay) { 260 updateTextView(views, R.id.when, eventInfo.visibWhen, eventInfo.when); 261 updateTextView(views, R.id.where, eventInfo.visibWhere, eventInfo.where); 262 } 263 updateTextView(views, R.id.title, eventInfo.visibTitle, eventInfo.title); 264 265 views.setViewVisibility(R.id.agenda_item_color, View.VISIBLE); 266 267 int selfAttendeeStatus = eventInfo.selfAttendeeStatus; 268 if (eventInfo.allDay) { 269 if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) { 270 views.setInt(R.id.agenda_item_color, "setImageResource", 271 R.drawable.widget_chip_not_responded_bg); 272 views.setInt(R.id.title, "setTextColor", displayColor); 273 } else { 274 views.setInt(R.id.agenda_item_color, "setImageResource", 275 R.drawable.widget_chip_responded_bg); 276 views.setInt(R.id.title, "setTextColor", mAllDayColor); 277 } 278 if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) { 279 // 40% opacity 280 views.setInt(R.id.agenda_item_color, "setColorFilter", 281 Utils.getDeclinedColorFromColor(displayColor)); 282 } else { 283 views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor); 284 } 285 } else if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) { 286 views.setInt(R.id.title, "setTextColor", mDeclinedColor); 287 views.setInt(R.id.when, "setTextColor", mDeclinedColor); 288 views.setInt(R.id.where, "setTextColor", mDeclinedColor); 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 5; 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 mLastSerialNum = ++mSerialNum; 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 (cursor.isClosed()) { 482 Log.wtf(TAG, "Got a closed cursor from onLoadComplete"); 483 return; 484 } 485 486 if (mLastSerialNum != mSerialNum) { 487 return; 488 } 489 490 final long now = System.currentTimeMillis(); 491 String tz = Utils.getTimeZone(mContext, mTimezoneChanged); 492 493 // Copy it to a local static cursor. 494 MatrixCursor matrixCursor = Utils.matrixCursorFromCursor(cursor); 495 try { 496 mModel = buildAppWidgetModel(mContext, matrixCursor, tz); 497 } finally { 498 if (matrixCursor != null) { 499 matrixCursor.close(); 500 } 501 502 if (cursor != null) { 503 cursor.close(); 504 } 505 } 506 507 // Schedule an alarm to wake ourselves up for the next update. 508 // We also cancel 509 // all existing wake-ups because PendingIntents don't match 510 // against extras. 511 long triggerTime = calculateUpdateTime(mModel, now, tz); 512 513 // If no next-update calculated, or bad trigger time in past, 514 // schedule 515 // update about six hours from now. 516 if (triggerTime < now) { 517 Log.w(TAG, "Encountered bad trigger time " + formatDebugTime(triggerTime, now)); 518 triggerTime = now + UPDATE_TIME_NO_EVENTS; 519 } 520 521 final AlarmManager alertManager = (AlarmManager) mContext 522 .getSystemService(Context.ALARM_SERVICE); 523 final PendingIntent pendingUpdate = CalendarAppWidgetProvider 524 .getUpdateIntent(mContext); 525 526 alertManager.cancel(pendingUpdate); 527 alertManager.set(AlarmManager.RTC, triggerTime, pendingUpdate); 528 Time time = new Time(Utils.getTimeZone(mContext, null)); 529 time.setToNow(); 530 531 if (time.normalize(true) != sLastUpdateTime) { 532 Time time2 = new Time(Utils.getTimeZone(mContext, null)); 533 time2.set(sLastUpdateTime); 534 time2.normalize(true); 535 if (time.year != time2.year || time.yearDay != time2.yearDay) { 536 final Intent updateIntent = new Intent( 537 Utils.getWidgetUpdateAction(mContext)); 538 mContext.sendBroadcast(updateIntent); 539 } 540 541 sLastUpdateTime = time.toMillis(true); 542 } 543 544 AppWidgetManager widgetManager = AppWidgetManager.getInstance(mContext); 545 if (widgetManager == null) { 546 return; 547 } 548 if (mAppWidgetId == -1) { 549 int[] ids = widgetManager.getAppWidgetIds(CalendarAppWidgetProvider 550 .getComponentName(mContext)); 551 552 widgetManager.notifyAppWidgetViewDataChanged(ids, R.id.events_list); 553 } else { 554 widgetManager.notifyAppWidgetViewDataChanged(mAppWidgetId, R.id.events_list); 555 } 556 } 557 } 558 559 @Override 560 public void onReceive(Context context, Intent intent) { 561 if (LOGD) 562 Log.d(TAG, "AppWidgetService received an intent. It was " + intent.toString()); 563 mContext = context; 564 565 // We cannot do any queries from the UI thread, so push the 'selection' query 566 // to a background thread. However the implementation of the latter query 567 // (cursor loading) uses CursorLoader which must be initiated from the UI thread, 568 // so there is some convoluted handshaking here. 569 // 570 // Note that as currently implemented, this must run in a single threaded executor 571 // or else the loads may be run out of order. 572 // 573 // TODO: Remove use of mHandler and CursorLoader, and do all the work synchronously 574 // in the background thread. All the handshaking going on here between the UI and 575 // background thread with using goAsync, mHandler, and CursorLoader is confusing. 576 final PendingResult result = goAsync(); 577 executor.submit(new Runnable() { 578 @Override 579 public void run() { 580 // We always complete queryForSelection() even if the load task ends up being 581 // canceled because of a more recent one. Optimizing this to allow 582 // canceling would require keeping track of all the PendingResults 583 // (from goAsync) to abort them. Defer this until it becomes a problem. 584 final String selection = queryForSelection(); 585 586 if (mLoader == null) { 587 mAppWidgetId = -1; 588 mHandler.post(new Runnable() { 589 @Override 590 public void run() { 591 initLoader(selection); 592 result.finish(); 593 } 594 }); 595 } else { 596 mHandler.post(createUpdateLoaderRunnable(selection, result, 597 currentVersion.incrementAndGet())); 598 } 599 } 600 }); 601 } 602 } 603 604 /** 605 * Format given time for debugging output. 606 * 607 * @param unixTime Target time to report. 608 * @param now Current system time from {@link System#currentTimeMillis()} 609 * for calculating time difference. 610 */ 611 static String formatDebugTime(long unixTime, long now) { 612 Time time = new Time(); 613 time.set(unixTime); 614 615 long delta = unixTime - now; 616 if (delta > DateUtils.MINUTE_IN_MILLIS) { 617 delta /= DateUtils.MINUTE_IN_MILLIS; 618 return String.format("[%d] %s (%+d mins)", unixTime, 619 time.format("%H:%M:%S"), delta); 620 } else { 621 delta /= DateUtils.SECOND_IN_MILLIS; 622 return String.format("[%d] %s (%+d secs)", unixTime, 623 time.format("%H:%M:%S"), delta); 624 } 625 } 626 } 627