blob: ec702c7c4f5b083446a7dce38f18f2551573d84a [file] [log] [blame]
/*
* Copyright (C) 2009 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.calendar.widget;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.appwidget.AppWidgetManager;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.CursorLoader;
import android.content.Intent;
import android.content.Loader;
import android.content.res.Resources;
import android.database.Cursor;
import android.database.MatrixCursor;
import android.net.Uri;
import android.os.Handler;
import android.provider.CalendarContract.Attendees;
import android.provider.CalendarContract.Calendars;
import android.provider.CalendarContract.Instances;
import android.text.format.DateUtils;
import android.text.format.Time;
import android.util.Log;
import android.view.View;
import android.widget.RemoteViews;
import android.widget.RemoteViewsService;
import com.android.calendar.R;
import com.android.calendar.Utils;
import com.android.calendar.widget.CalendarAppWidgetModel.DayInfo;
import com.android.calendar.widget.CalendarAppWidgetModel.EventInfo;
import com.android.calendar.widget.CalendarAppWidgetModel.RowInfo;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
public class CalendarAppWidgetService extends RemoteViewsService {
private static final String TAG = "CalendarWidget";
static final int EVENT_MIN_COUNT = 20;
static final int EVENT_MAX_COUNT = 100;
// Minimum delay between queries on the database for widget updates in ms
static final int WIDGET_UPDATE_THROTTLE = 500;
private static final String EVENT_SORT_ORDER = Instances.START_DAY + " ASC, "
+ Instances.START_MINUTE + " ASC, " + Instances.END_DAY + " ASC, "
+ Instances.END_MINUTE + " ASC LIMIT " + EVENT_MAX_COUNT;
private static final String EVENT_SELECTION = Calendars.VISIBLE + "=1";
private static final String EVENT_SELECTION_HIDE_DECLINED = Calendars.VISIBLE + "=1 AND "
+ Instances.SELF_ATTENDEE_STATUS + "!=" + Attendees.ATTENDEE_STATUS_DECLINED;
static final String[] EVENT_PROJECTION = new String[] {
Instances.ALL_DAY,
Instances.BEGIN,
Instances.END,
Instances.TITLE,
Instances.EVENT_LOCATION,
Instances.EVENT_ID,
Instances.START_DAY,
Instances.END_DAY,
Instances.DISPLAY_COLOR, // If SDK < 16, set to Instances.CALENDAR_COLOR.
Instances.SELF_ATTENDEE_STATUS,
};
static final int INDEX_ALL_DAY = 0;
static final int INDEX_BEGIN = 1;
static final int INDEX_END = 2;
static final int INDEX_TITLE = 3;
static final int INDEX_EVENT_LOCATION = 4;
static final int INDEX_EVENT_ID = 5;
static final int INDEX_START_DAY = 6;
static final int INDEX_END_DAY = 7;
static final int INDEX_COLOR = 8;
static final int INDEX_SELF_ATTENDEE_STATUS = 9;
static {
if (!Utils.isJellybeanOrLater()) {
EVENT_PROJECTION[INDEX_COLOR] = Instances.CALENDAR_COLOR;
}
}
static final int MAX_DAYS = 7;
private static final long SEARCH_DURATION = MAX_DAYS * DateUtils.DAY_IN_MILLIS;
/**
* Update interval used when no next-update calculated, or bad trigger time in past.
* Unit: milliseconds.
*/
private static final long UPDATE_TIME_NO_EVENTS = DateUtils.HOUR_IN_MILLIS * 6;
@Override
public RemoteViewsFactory onGetViewFactory(Intent intent) {
return new CalendarFactory(getApplicationContext(), intent);
}
public static class CalendarFactory extends BroadcastReceiver implements
RemoteViewsService.RemoteViewsFactory, Loader.OnLoadCompleteListener<Cursor> {
private static final boolean LOGD = false;
// Suppress unnecessary logging about update time. Need to be static as this object is
// re-instanciated frequently.
// TODO: It seems loadData() is called via onCreate() four times, which should mean
// unnecessary CalendarFactory object is created and dropped. It is not efficient.
private static long sLastUpdateTime = UPDATE_TIME_NO_EVENTS;
private Context mContext;
private Resources mResources;
private static CalendarAppWidgetModel mModel;
private static Object mLock = new Object();
private static volatile int mSerialNum = 0;
private int mLastSerialNum = -1;
private CursorLoader mLoader;
private final Handler mHandler = new Handler();
private static final AtomicInteger currentVersion = new AtomicInteger(0);
private final ExecutorService executor = Executors.newSingleThreadExecutor();
private int mAppWidgetId;
private int mDeclinedColor;
private int mStandardColor;
private int mAllDayColor;
private final Runnable mTimezoneChanged = new Runnable() {
@Override
public void run() {
if (mLoader != null) {
mLoader.forceLoad();
}
}
};
private Runnable createUpdateLoaderRunnable(final String selection,
final PendingResult result, final int version) {
return new Runnable() {
@Override
public void run() {
// If there is a newer load request in the queue, skip loading.
if (mLoader != null && version >= currentVersion.get()) {
Uri uri = createLoaderUri();
mLoader.setUri(uri);
mLoader.setSelection(selection);
synchronized (mLock) {
mLastSerialNum = ++mSerialNum;
}
mLoader.forceLoad();
}
result.finish();
}
};
}
protected CalendarFactory(Context context, Intent intent) {
mContext = context;
mResources = context.getResources();
mAppWidgetId = intent.getIntExtra(
AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID);
mDeclinedColor = mResources.getColor(R.color.appwidget_item_declined_color);
mStandardColor = mResources.getColor(R.color.appwidget_item_standard_color);
mAllDayColor = mResources.getColor(R.color.appwidget_item_allday_color);
}
public CalendarFactory() {
// This is being created as part of onReceive
}
@Override
public void onCreate() {
String selection = queryForSelection();
initLoader(selection);
}
@Override
public void onDataSetChanged() {
}
@Override
public void onDestroy() {
if (mLoader != null) {
mLoader.reset();
}
}
@Override
public RemoteViews getLoadingView() {
RemoteViews views = new RemoteViews(mContext.getPackageName(),
R.layout.appwidget_loading);
return views;
}
@Override
public RemoteViews getViewAt(int position) {
// we use getCount here so that it doesn't return null when empty
if (position < 0 || position >= getCount()) {
return null;
}
if (mModel == null) {
RemoteViews views = new RemoteViews(mContext.getPackageName(),
R.layout.appwidget_loading);
final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0,
0, 0, false);
views.setOnClickFillInIntent(R.id.appwidget_loading, intent);
return views;
}
if (mModel.mEventInfos.isEmpty() || mModel.mRowInfos.isEmpty()) {
RemoteViews views = new RemoteViews(mContext.getPackageName(),
R.layout.appwidget_no_events);
final Intent intent = CalendarAppWidgetProvider.getLaunchFillInIntent(mContext, 0,
0, 0, false);
views.setOnClickFillInIntent(R.id.appwidget_no_events, intent);
return views;
}
RowInfo rowInfo = mModel.mRowInfos.get(position);
if (rowInfo.mType == RowInfo.TYPE_DAY) {
RemoteViews views = new RemoteViews(mContext.getPackageName(),
R.layout.appwidget_day);
DayInfo dayInfo = mModel.mDayInfos.get(rowInfo.mIndex);
updateTextView(views, R.id.date, View.VISIBLE, dayInfo.mDayLabel);
return views;
} else {
RemoteViews views;
final EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex);
if (eventInfo.allDay) {
views = new RemoteViews(mContext.getPackageName(),
R.layout.widget_all_day_item);
} else {
views = new RemoteViews(mContext.getPackageName(), R.layout.widget_item);
}
int displayColor = Utils.getDisplayColorFromColor(eventInfo.color);
final long now = System.currentTimeMillis();
if (!eventInfo.allDay && eventInfo.start <= now && now <= eventInfo.end) {
views.setInt(R.id.widget_row, "setBackgroundResource",
R.drawable.agenda_item_bg_secondary);
} else {
views.setInt(R.id.widget_row, "setBackgroundResource",
R.drawable.agenda_item_bg_primary);
}
if (!eventInfo.allDay) {
updateTextView(views, R.id.when, eventInfo.visibWhen, eventInfo.when);
updateTextView(views, R.id.where, eventInfo.visibWhere, eventInfo.where);
}
updateTextView(views, R.id.title, eventInfo.visibTitle, eventInfo.title);
views.setViewVisibility(R.id.agenda_item_color, View.VISIBLE);
int selfAttendeeStatus = eventInfo.selfAttendeeStatus;
if (eventInfo.allDay) {
if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) {
views.setInt(R.id.agenda_item_color, "setImageResource",
R.drawable.widget_chip_not_responded_bg);
views.setInt(R.id.title, "setTextColor", displayColor);
} else {
views.setInt(R.id.agenda_item_color, "setImageResource",
R.drawable.widget_chip_responded_bg);
views.setInt(R.id.title, "setTextColor", mAllDayColor);
}
if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) {
// 40% opacity
views.setInt(R.id.agenda_item_color, "setColorFilter",
Utils.getDeclinedColorFromColor(displayColor));
} else {
views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor);
}
} else if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_DECLINED) {
views.setInt(R.id.title, "setTextColor", mDeclinedColor);
views.setInt(R.id.when, "setTextColor", mDeclinedColor);
views.setInt(R.id.where, "setTextColor", mDeclinedColor);
views.setInt(R.id.agenda_item_color, "setImageResource",
R.drawable.widget_chip_responded_bg);
// 40% opacity
views.setInt(R.id.agenda_item_color, "setColorFilter",
Utils.getDeclinedColorFromColor(displayColor));
} else {
views.setInt(R.id.title, "setTextColor", mStandardColor);
views.setInt(R.id.when, "setTextColor", mStandardColor);
views.setInt(R.id.where, "setTextColor", mStandardColor);
if (selfAttendeeStatus == Attendees.ATTENDEE_STATUS_INVITED) {
views.setInt(R.id.agenda_item_color, "setImageResource",
R.drawable.widget_chip_not_responded_bg);
} else {
views.setInt(R.id.agenda_item_color, "setImageResource",
R.drawable.widget_chip_responded_bg);
}
views.setInt(R.id.agenda_item_color, "setColorFilter", displayColor);
}
long start = eventInfo.start;
long end = eventInfo.end;
// An element in ListView.
if (eventInfo.allDay) {
String tz = Utils.getTimeZone(mContext, null);
Time recycle = new Time();
start = Utils.convertAlldayLocalToUTC(recycle, start, tz);
end = Utils.convertAlldayLocalToUTC(recycle, end, tz);
}
final Intent fillInIntent = CalendarAppWidgetProvider.getLaunchFillInIntent(
mContext, eventInfo.id, start, end, eventInfo.allDay);
views.setOnClickFillInIntent(R.id.widget_row, fillInIntent);
return views;
}
}
@Override
public int getViewTypeCount() {
return 5;
}
@Override
public int getCount() {
// if there are no events, we still return 1 to represent the "no
// events" view
if (mModel == null) {
return 1;
}
return Math.max(1, mModel.mRowInfos.size());
}
@Override
public long getItemId(int position) {
if (mModel == null || mModel.mRowInfos.isEmpty() || position >= getCount()) {
return 0;
}
RowInfo rowInfo = mModel.mRowInfos.get(position);
if (rowInfo.mType == RowInfo.TYPE_DAY) {
return rowInfo.mIndex;
}
EventInfo eventInfo = mModel.mEventInfos.get(rowInfo.mIndex);
long prime = 31;
long result = 1;
result = prime * result + (int) (eventInfo.id ^ (eventInfo.id >>> 32));
result = prime * result + (int) (eventInfo.start ^ (eventInfo.start >>> 32));
return result;
}
@Override
public boolean hasStableIds() {
return true;
}
/**
* Query across all calendars for upcoming event instances from now
* until some time in the future. Widen the time range that we query by
* one day on each end so that we can catch all-day events. All-day
* events are stored starting at midnight in UTC but should be included
* in the list of events starting at midnight local time. This may fetch
* more events than we actually want, so we filter them out later.
*
* @param selection The selection string for the loader to filter the query with.
*/
public void initLoader(String selection) {
if (LOGD)
Log.d(TAG, "Querying for widget events...");
// Search for events from now until some time in the future
Uri uri = createLoaderUri();
mLoader = new CursorLoader(mContext, uri, EVENT_PROJECTION, selection, null,
EVENT_SORT_ORDER);
mLoader.setUpdateThrottle(WIDGET_UPDATE_THROTTLE);
synchronized (mLock) {
mLastSerialNum = ++mSerialNum;
}
mLoader.registerListener(mAppWidgetId, this);
mLoader.startLoading();
}
/**
* This gets the selection string for the loader. This ends up doing a query in the
* shared preferences.
*/
private String queryForSelection() {
return Utils.getHideDeclinedEvents(mContext) ? EVENT_SELECTION_HIDE_DECLINED
: EVENT_SELECTION;
}
/**
* @return The uri for the loader
*/
private Uri createLoaderUri() {
long now = System.currentTimeMillis();
// Add a day on either side to catch all-day events
long begin = now - DateUtils.DAY_IN_MILLIS;
long end = now + SEARCH_DURATION + DateUtils.DAY_IN_MILLIS;
Uri uri = Uri.withAppendedPath(Instances.CONTENT_URI, Long.toString(begin) + "/" + end);
return uri;
}
/* @VisibleForTesting */
protected static CalendarAppWidgetModel buildAppWidgetModel(
Context context, Cursor cursor, String timeZone) {
CalendarAppWidgetModel model = new CalendarAppWidgetModel(context, timeZone);
model.buildFromCursor(cursor, timeZone);
return model;
}
/**
* Calculates and returns the next time we should push widget updates.
*/
private long calculateUpdateTime(CalendarAppWidgetModel model, long now, String timeZone) {
// Make sure an update happens at midnight or earlier
long minUpdateTime = getNextMidnightTimeMillis(timeZone);
for (EventInfo event : model.mEventInfos) {
final long start;
final long end;
start = event.start;
end = event.end;
// We want to update widget when we enter/exit time range of an event.
if (now < start) {
minUpdateTime = Math.min(minUpdateTime, start);
} else if (now < end) {
minUpdateTime = Math.min(minUpdateTime, end);
}
}
return minUpdateTime;
}
private static long getNextMidnightTimeMillis(String timezone) {
Time time = new Time();
time.setToNow();
time.monthDay++;
time.hour = 0;
time.minute = 0;
time.second = 0;
long midnightDeviceTz = time.normalize(true);
time.timezone = timezone;
time.setToNow();
time.monthDay++;
time.hour = 0;
time.minute = 0;
time.second = 0;
long midnightHomeTz = time.normalize(true);
return Math.min(midnightDeviceTz, midnightHomeTz);
}
static void updateTextView(RemoteViews views, int id, int visibility, String string) {
views.setViewVisibility(id, visibility);
if (visibility == View.VISIBLE) {
views.setTextViewText(id, string);
}
}
/*
* (non-Javadoc)
* @see
* android.content.Loader.OnLoadCompleteListener#onLoadComplete(android
* .content.Loader, java.lang.Object)
*/
@Override
public void onLoadComplete(Loader<Cursor> loader, Cursor cursor) {
if (cursor == null) {
return;
}
// If a newer update has happened since we started clean up and
// return
synchronized (mLock) {
if (cursor.isClosed()) {
Log.wtf(TAG, "Got a closed cursor from onLoadComplete");
return;
}
if (mLastSerialNum != mSerialNum) {
return;
}
final long now = System.currentTimeMillis();
String tz = Utils.getTimeZone(mContext, mTimezoneChanged);
// Copy it to a local static cursor.
MatrixCursor matrixCursor = Utils.matrixCursorFromCursor(cursor);
try {
mModel = buildAppWidgetModel(mContext, matrixCursor, tz);
} finally {
if (matrixCursor != null) {
matrixCursor.close();
}
if (cursor != null) {
cursor.close();
}
}
// Schedule an alarm to wake ourselves up for the next update.
// We also cancel
// all existing wake-ups because PendingIntents don't match
// against extras.
long triggerTime = calculateUpdateTime(mModel, now, tz);
// If no next-update calculated, or bad trigger time in past,
// schedule
// update about six hours from now.
if (triggerTime < now) {
Log.w(TAG, "Encountered bad trigger time " + formatDebugTime(triggerTime, now));
triggerTime = now + UPDATE_TIME_NO_EVENTS;
}
final AlarmManager alertManager = (AlarmManager) mContext
.getSystemService(Context.ALARM_SERVICE);
final PendingIntent pendingUpdate = CalendarAppWidgetProvider
.getUpdateIntent(mContext);
alertManager.cancel(pendingUpdate);
alertManager.set(AlarmManager.RTC, triggerTime, pendingUpdate);
Time time = new Time(Utils.getTimeZone(mContext, null));
time.setToNow();
if (time.normalize(true) != sLastUpdateTime) {
Time time2 = new Time(Utils.getTimeZone(mContext, null));
time2.set(sLastUpdateTime);
time2.normalize(true);
if (time.year != time2.year || time.yearDay != time2.yearDay) {
final Intent updateIntent = new Intent(
Utils.getWidgetUpdateAction(mContext));
mContext.sendBroadcast(updateIntent);
}
sLastUpdateTime = time.toMillis(true);
}
AppWidgetManager widgetManager = AppWidgetManager.getInstance(mContext);
if (widgetManager == null) {
return;
}
if (mAppWidgetId == -1) {
int[] ids = widgetManager.getAppWidgetIds(CalendarAppWidgetProvider
.getComponentName(mContext));
widgetManager.notifyAppWidgetViewDataChanged(ids, R.id.events_list);
} else {
widgetManager.notifyAppWidgetViewDataChanged(mAppWidgetId, R.id.events_list);
}
}
}
@Override
public void onReceive(Context context, Intent intent) {
if (LOGD)
Log.d(TAG, "AppWidgetService received an intent. It was " + intent.toString());
mContext = context;
// We cannot do any queries from the UI thread, so push the 'selection' query
// to a background thread. However the implementation of the latter query
// (cursor loading) uses CursorLoader which must be initiated from the UI thread,
// so there is some convoluted handshaking here.
//
// Note that as currently implemented, this must run in a single threaded executor
// or else the loads may be run out of order.
//
// TODO: Remove use of mHandler and CursorLoader, and do all the work synchronously
// in the background thread. All the handshaking going on here between the UI and
// background thread with using goAsync, mHandler, and CursorLoader is confusing.
final PendingResult result = goAsync();
executor.submit(new Runnable() {
@Override
public void run() {
// We always complete queryForSelection() even if the load task ends up being
// canceled because of a more recent one. Optimizing this to allow
// canceling would require keeping track of all the PendingResults
// (from goAsync) to abort them. Defer this until it becomes a problem.
final String selection = queryForSelection();
if (mLoader == null) {
mAppWidgetId = -1;
mHandler.post(new Runnable() {
@Override
public void run() {
initLoader(selection);
result.finish();
}
});
} else {
mHandler.post(createUpdateLoaderRunnable(selection, result,
currentVersion.incrementAndGet()));
}
}
});
}
}
/**
* Format given time for debugging output.
*
* @param unixTime Target time to report.
* @param now Current system time from {@link System#currentTimeMillis()}
* for calculating time difference.
*/
static String formatDebugTime(long unixTime, long now) {
Time time = new Time();
time.set(unixTime);
long delta = unixTime - now;
if (delta > DateUtils.MINUTE_IN_MILLIS) {
delta /= DateUtils.MINUTE_IN_MILLIS;
return String.format("[%d] %s (%+d mins)", unixTime,
time.format("%H:%M:%S"), delta);
} else {
delta /= DateUtils.SECOND_IN_MILLIS;
return String.format("[%d] %s (%+d secs)", unixTime,
time.format("%H:%M:%S"), delta);
}
}
}