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 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 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, "setDrawStyle", 290 // ColorChipView.DRAW_CROSS_HATCHED); 291 views.setInt(R.id.agenda_item_color, "setImageResource", 292 R.drawable.widget_chip_responded_bg); 293 // 40% opacity 294 views.setInt(R.id.agenda_item_color, "setColorFilter", 295 Utils.getDeclinedColorFromColor(displayColor)); 296 } else { 297 views.setInt(R.id.title, "setTextColor", mStandardColor); 298 views.setInt(R.id.when, "setTextColor", mStandardColor); 299 views.setInt(R.id.where, "setTextColor", mStandardColor); 300 if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) { 301 views.setInt(R.id.agenda_item_color, "setImageResource", 302 R.drawable.widget_chip_not_responded_bg); 303 } else { 304 views.setInt(R.id.agenda_item_color, "setImageResource", 305 R.drawable.widget_chip_responded_bg); 306 } 307 views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor); 308 } 309 310 long start = eventInfo.start; 311 long end = eventInfo.end; 312 // An element in ListView. 313 if (eventInfo.allDay) { 314 String tz = Utils.getTimeZone(mContext, null); 315 Time recycle = new Time(); 316 start = Utils.convertAlldayLocalToUTC(recycle, start, tz); 317 end = Utils.convertAlldayLocalToUTC(recycle, end, tz); 318 } 319 final Intent fillInIntent = CalendarAppWidgetProvider.getLaunchFillInIntent( 320 mContext, eventInfo.id, start, end, eventInfo.allDay); 321 views.setOnClickFillInIntent(R.id.widget_row, fillInIntent); 322 return views; 323 } 324 } 325 326 @Override 327 public int getViewTypeCount() { 328 return 5; 329 } 330 331 @Override 332 public int getCount() { 333 // if there are no events, we still return 1 to represent the "no 334 // events" view 335 if (mModel == null) { 336 return 1; 337 } 338 return Math.max(1, mModel.mRowInfos.size()); 339 } 340 341 @Override 342 public long getItemId(int position) { 343 if (mModel == null || mModel.mRowInfos.isEmpty() || position >= getCount()) { 344 return 0; 345 } 346 RowInfo rowInfo = mModel.mRowInfos.get(position); 347 if (rowInfo.mType == RowInfo.TYPE_DAY) { 348 return rowInfo.mIndex; 349 } 350 EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex); 351 long prime = 31; 352 long result = 1; 353 result = prime * result + (int) (eventInfo.id ^ (eventInfo.id >>> 32)); 354 result = prime * result + (int) (eventInfo.start ^ (eventInfo.start >>> 32)); 355 return result; 356 } 357 358 @Override 359 public boolean hasStableIds() { 360 return true; 361 } 362 363 /** 364 * Query across all calendars for upcoming event instances from now 365 * until some time in the future. Widen the time range that we query by 366 * one day on each end so that we can catch all-day events. All-day 367 * events are stored starting at midnight in UTC but should be included 368 * in the list of events starting at midnight local time. This may fetch 369 * more events than we actually want, so we filter them out later. 370 * 371 * @param selection The selection string for the loader to filter the query with. 372 */ 373 public void initLoader(String selection) { 374 if (LOGD) 375 Log.d(TAG, "Querying for widget events..."); 376 377 // Search for events from now until some time in the future 378 Uri uri = createLoaderUri(); 379 mLoader = new CursorLoader(mContext, uri, EVENT_PROJECTION, selection, null, 380 EVENT_SORT_ORDER); 381 mLoader.setUpdateThrottle(WIDGET_UPDATE_THROTTLE); 382 synchronized (mLock) { 383 mLastSerialNum = ++mSerialNum; 384 } 385 mLoader.registerListener(mAppWidgetId, this); 386 mLoader.startLoading(); 387 388 } 389 390 /** 391 * This gets the selection string for the loader. This ends up doing a query in the 392 * shared preferences. 393 */ 394 private String queryForSelection() { 395 return Utils.getHideDeclinedEvents(mContext) ? EVENT_SELECTION_HIDE_DECLINED 396 : EVENT_SELECTION; 397 } 398 399 /** 400 * @return The uri for the loader 401 */ 402 private Uri createLoaderUri() { 403 long now = System.currentTimeMillis(); 404 // Add a day on either side to catch all-day events 405 long begin = now - DateUtils.DAY_IN_MILLIS; 406 long end = now + SEARCH_DURATION + DateUtils.DAY_IN_MILLIS; 407 408 Uri uri = Uri.withAppendedPath(Instances.CONTENT_URI, Long.toString(begin) + "/" + end); 409 return uri; 410 } 411 412 /* @VisibleForTesting */ 413 protected static CalendarAppWidgetModel buildAppWidgetModel( 414 Context context, Cursor cursor, String timeZone) { 415 CalendarAppWidgetModel model = new CalendarAppWidgetModel(context, timeZone); 416 model.buildFromCursor(cursor, timeZone); 417 return model; 418 } 419 420 /** 421 * Calculates and returns the next time we should push widget updates. 422 */ 423 private long calculateUpdateTime(CalendarAppWidgetModel model, long now, String timeZone) { 424 // Make sure an update happens at midnight or earlier 425 long minUpdateTime = getNextMidnightTimeMillis(timeZone); 426 for (EventInfo event : model.mEventInfos) { 427 final long start; 428 final long end; 429 start = event.start; 430 end = event.end; 431 432 // We want to update widget when we enter/exit time range of an event. 433 if (now < start) { 434 minUpdateTime = Math.min(minUpdateTime, start); 435 } else if (now < end) { 436 minUpdateTime = Math.min(minUpdateTime, end); 437 } 438 } 439 return minUpdateTime; 440 } 441 442 private static long getNextMidnightTimeMillis(String timezone) { 443 Time time = new Time(); 444 time.setToNow(); 445 time.monthDay++; 446 time.hour = 0; 447 time.minute = 0; 448 time.second = 0; 449 long midnightDeviceTz = time.normalize(true); 450 451 time.timezone = timezone; 452 time.setToNow(); 453 time.monthDay++; 454 time.hour = 0; 455 time.minute = 0; 456 time.second = 0; 457 long midnightHomeTz = time.normalize(true); 458 459 return Math.min(midnightDeviceTz, midnightHomeTz); 460 } 461 462 static void updateTextView(RemoteViews views, int id, int visibility, String string) { 463 views.setViewVisibility(id, visibility); 464 if (visibility == View.VISIBLE) { 465 views.setTextViewText(id, string); 466 } 467 } 468 469 /* 470 * (non-Javadoc) 471 * @see 472 * android.content.Loader.OnLoadCompleteListener#onLoadComplete(android 473 * .content.Loader, java.lang.Object) 474 */ 475 @Override 476 public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) { 477 if (cursor == null) { 478 return; 479 } 480 // If a newer update has happened since we started clean up and 481 // return 482 synchronized (mLock) { 483 if (cursor.isClosed()) { 484 Log.wtf(TAG, "Got a closed cursor from onLoadComplete"); 485 return; 486 } 487 488 if (mLastSerialNum != mSerialNum) { 489 return; 490 } 491 492 final long now = System.currentTimeMillis(); 493 String tz = Utils.getTimeZone(mContext, mTimezoneChanged); 494 495 // Copy it to a local static cursor. 496 MatrixCursor matrixCursor = Utils.matrixCursorFromCursor(cursor); 497 try { 498 mModel = buildAppWidgetModel(mContext, matrixCursor, tz); 499 } finally { 500 if (matrixCursor != null) { 501 matrixCursor.close(); 502 } 503 504 if (cursor != null) { 505 cursor.close(); 506 } 507 } 508 509 // Schedule an alarm to wake ourselves up for the next update. 510 // We also cancel 511 // all existing wake-ups because PendingIntents don't match 512 // against extras. 513 long triggerTime = calculateUpdateTime(mModel, now, tz); 514 515 // If no next-update calculated, or bad trigger time in past, 516 // schedule 517 // update about six hours from now. 518 if (triggerTime < now) { 519 Log.w(TAG, "Encountered bad trigger time " + formatDebugTime(triggerTime, now)); 520 triggerTime = now + UPDATE_TIME_NO_EVENTS; 521 } 522 523 final AlarmManager alertManager = (AlarmManager) mContext 524 .getSystemService(Context.ALARM_SERVICE); 525 final PendingIntent pendingUpdate = CalendarAppWidgetProvider 526 .getUpdateIntent(mContext); 527 528 alertManager.cancel(pendingUpdate); 529 alertManager.set(AlarmManager.RTC, triggerTime, pendingUpdate); 530 Time time = new Time(Utils.getTimeZone(mContext, null)); 531 time.setToNow(); 532 533 if (time.normalize(true) != sLastUpdateTime) { 534 Time time2 = new Time(Utils.getTimeZone(mContext, null)); 535 time2.set(sLastUpdateTime); 536 time2.normalize(true); 537 if (time.year != time2.year || time.yearDay != time2.yearDay) { 538 final Intent updateIntent = new Intent( 539 Utils.getWidgetUpdateAction(mContext)); 540 mContext.sendBroadcast(updateIntent); 541 } 542 543 sLastUpdateTime = time.toMillis(true); 544 } 545 546 AppWidgetManager widgetManager = AppWidgetManager.getInstance(mContext); 547 if (mAppWidgetId == -1) { 548 int[] ids = widgetManager.getAppWidgetIds(CalendarAppWidgetProvider 549 .getComponentName(mContext)); 550 551 widgetManager.notifyAppWidgetViewDataChanged(ids, R.id.events_list); 552 } else { 553 widgetManager.notifyAppWidgetViewDataChanged(mAppWidgetId, R.id.events_list); 554 } 555 } 556 } 557 558 @Override 559 public void onReceive(Context context, Intent intent) { 560 if (LOGD) 561 Log.d(TAG, "AppWidgetService received an intent. It was " + intent.toString()); 562 mContext = context; 563 564 // We cannot do any queries from the UI thread, so push the 'selection' query 565 // to a background thread. However the implementation of the latter query 566 // (cursor loading) uses CursorLoader which must be initiated from the UI thread, 567 // so there is some convoluted handshaking here. 568 // 569 // Note that as currently implemented, this must run in a single threaded executor 570 // or else the loads may be run out of order. 571 // 572 // TODO: Remove use of mHandler and CursorLoader, and do all the work synchronously 573 // in the background thread. All the handshaking going on here between the UI and 574 // background thread with using goAsync, mHandler, and CursorLoader is confusing. 575 final PendingResult result = goAsync(); 576 executor.submit(new Runnable() { 577 @Override 578 public void run() { 579 // We always complete queryForSelection() even if the load task ends up being 580 // canceled because of a more recent one. Optimizing this to allow 581 // canceling would require keeping track of all the PendingResults 582 // (from goAsync) to abort them. Defer this until it becomes a problem. 583 final String selection = queryForSelection(); 584 585 if (mLoader == null) { 586 mAppWidgetId = -1; 587 mHandler.post(new Runnable() { 588 @Override 589 public void run() { 590 initLoader(selection); 591 result.finish(); 592 } 593 }); 594 } else { 595 mHandler.post(createUpdateLoaderRunnable(selection, result, 596 currentVersion.incrementAndGet())); 597 } 598 } 599 }); 600 } 601 } 602 603 /** 604 * Format given time for debugging output. 605 * 606 * @param unixTime Target time to report. 607 * @param now Current system time from {@link System#currentTimeMillis()} 608 * for calculating time difference. 609 */ 610 static String formatDebugTime(long unixTime, long now) { 611 Time time = new Time(); 612 time.set(unixTime); 613 614 long delta = unixTime - now; 615 if (delta > DateUtils.MINUTE_IN_MILLIS) { 616 delta /= DateUtils.MINUTE_IN_MILLIS; 617 return String.format("[%d] %s (%+d mins)", unixTime, 618 time.format("%H:%M:%S"), delta); 619 } else { 620 delta /= DateUtils.SECOND_IN_MILLIS; 621 return String.format("[%d] %s (%+d secs)", unixTime, 622 time.format("%H:%M:%S"), delta); 623 } 624 } 625 } 626