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