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