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