blob: 981ffcc9104189e8a275848ab0062f2f06a15ee0 [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.providers.calendar;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.app.Service;
import android.appwidget.AppWidgetManager;
import android.content.ComponentName;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.content.res.Resources;
import android.database.Cursor;
import android.net.Uri;
import android.os.IBinder;
import android.provider.Calendar;
import android.provider.Calendar.Attendees;
import android.provider.Calendar.Calendars;
import android.provider.Calendar.Instances;
import android.text.format.DateFormat;
import android.text.format.DateUtils;
import android.text.format.Time;
import android.util.Log;
import android.view.View;
import android.widget.RemoteViews;
import java.util.Set;
import java.util.TimeZone;
public class CalendarAppWidgetService extends Service implements Runnable {
static final String TAG = "CalendarAppWidgetService";
static final boolean LOGD = false;
static final String EVENT_SORT_ORDER = "startDay ASC, allDay DESC, begin ASC";
static final String[] EVENT_PROJECTION = new String[] {
Instances.ALL_DAY,
Instances.BEGIN,
Instances.END,
Instances.COLOR,
Instances.TITLE,
Instances.RRULE,
Instances.HAS_ALARM,
Instances.EVENT_LOCATION,
Instances.CALENDAR_ID,
Instances.EVENT_ID,
};
static final int INDEX_ALL_DAY = 0;
static final int INDEX_BEGIN = 1;
static final int INDEX_END = 2;
static final int INDEX_COLOR = 3;
static final int INDEX_TITLE = 4;
static final int INDEX_RRULE = 5;
static final int INDEX_HAS_ALARM = 6;
static final int INDEX_EVENT_LOCATION = 7;
static final int INDEX_CALENDAR_ID = 8;
static final int INDEX_EVENT_ID = 9;
static final long SEARCH_DURATION = DateUtils.WEEK_IN_MILLIS;
static final long UPDATE_NO_EVENTS = DateUtils.HOUR_IN_MILLIS * 6;
static final String ACTION_PACKAGE = "com.android.calendar";
static final String ACTION_CLASS = "com.android.calendar.LaunchActivity";
static final String KEY_DETAIL_VIEW = "DETAIL_VIEW";
@Override
public void onStart(Intent intent, int startId) {
super.onStart(intent, startId);
// Only start processing thread if not already running
synchronized (AppWidgetShared.sLock) {
if (!AppWidgetShared.sUpdateRunning) {
if (LOGD) Log.d(TAG, "no thread running, so starting new one");
AppWidgetShared.sUpdateRunning = true;
new Thread(this).start();
}
}
}
@Override
public IBinder onBind(Intent intent) {
return null;
}
/**
* Thread loop to handle
*/
public void run() {
while (true) {
long now = -1;
int[] appWidgetIds;
Set<Long> changedEventIds;
synchronized (AppWidgetShared.sLock) {
// Bail out if no remaining updates
if (!AppWidgetShared.sUpdateRequested) {
// Clear current shared state, release wakelock, and stop service
if (LOGD) Log.d(TAG, "no requested update or expired wakelock, bailing");
AppWidgetShared.clearLocked();
stopSelf();
return;
}
// Clear requested flag and collect latest parameters
AppWidgetShared.sUpdateRequested = false;
now = AppWidgetShared.sLastRequest;
appWidgetIds = AppWidgetShared.collectAppWidgetIdsLocked();
changedEventIds = AppWidgetShared.collectChangedEventIdsLocked();
}
// Process this update
if (LOGD) Log.d(TAG, "processing requested update now=" + now);
performUpdate(this, appWidgetIds, changedEventIds, now);
}
}
/**
* Process and push out an update for the given appWidgetIds.
*
* @param context Context to use when updating widget.
* @param appWidgetIds List of appWidgetIds to update, or null for all.
* @param changedEventIds Specific events known to be changed, otherwise
* null. If present, we use to decide if an update is necessary.
* @param now System clock time to use during this update.
*/
private void performUpdate(Context context, int[] appWidgetIds,
Set<Long> changedEventIds, long now) {
ContentResolver resolver = context.getContentResolver();
Cursor cursor = null;
RemoteViews views = null;
long triggerTime = -1;
try {
cursor = getUpcomingInstancesCursor(resolver, SEARCH_DURATION, now);
if (cursor != null) {
MarkedEvents events = buildMarkedEvents(cursor, changedEventIds, now);
boolean shouldUpdate = true;
if (changedEventIds.size() > 0) {
shouldUpdate = events.watchFound;
}
if (events.primaryCount == 0) {
views = getAppWidgetNoEvents(context);
} else if (shouldUpdate) {
views = getAppWidgetUpdate(context, cursor, events);
triggerTime = calculateUpdateTime(cursor, events);
}
} else {
views = getAppWidgetNoEvents(context);
}
} finally {
if (cursor != null) {
cursor.close();
}
}
// Bail out early if no update built
if (views == null) {
if (LOGD) Log.d(TAG, "Didn't build update, possibly because changedEventIds=" +
changedEventIds.toString());
return;
}
AppWidgetManager gm = AppWidgetManager.getInstance(context);
if (appWidgetIds != null && appWidgetIds.length > 0) {
gm.updateAppWidget(appWidgetIds, views);
} else {
ComponentName thisWidget = CalendarAppWidgetProvider.getComponentName(context);
gm.updateAppWidget(thisWidget, views);
}
// 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.
// If no next-update calculated, or bad trigger time in past, schedule
// update about six hours from now.
if (triggerTime == -1 || triggerTime < now) {
if (LOGD) Log.w(TAG, "Encountered bad trigger time " + formatDebugTime(triggerTime, now));
triggerTime = now + UPDATE_NO_EVENTS;
}
AlarmManager am = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE);
PendingIntent pendingUpdate = CalendarAppWidgetProvider.getUpdateIntent(context);
am.cancel(pendingUpdate);
am.set(AlarmManager.RTC, triggerTime, pendingUpdate);
if (LOGD) Log.d(TAG, "Scheduled next update at " + formatDebugTime(triggerTime, now));
}
/**
* 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.
*/
private 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);
}
}
/**
* Convert given UTC time into current local time.
*
* @param recycle Time object to recycle, otherwise null.
* @param utcTime Time to convert, in UTC.
*/
private long convertUtcToLocal(Time recycle, long utcTime) {
if (recycle == null) {
recycle = new Time();
}
recycle.timezone = Time.TIMEZONE_UTC;
recycle.set(utcTime);
recycle.timezone = TimeZone.getDefault().getID();
return recycle.normalize(true);
}
/**
* Figure out the next time we should push widget updates, usually the time
* calculated by {@link #getEventFlip(Cursor, long, long, boolean)}.
*
* @param cursor Valid cursor on {@link Instances#CONTENT_URI}
* @param events {@link MarkedEvents} parsed from the cursor
*/
private long calculateUpdateTime(Cursor cursor, MarkedEvents events) {
long result = -1;
if (events.primaryRow != -1) {
cursor.moveToPosition(events.primaryRow);
long start = cursor.getLong(INDEX_BEGIN);
long end = cursor.getLong(INDEX_END);
boolean allDay = cursor.getInt(INDEX_ALL_DAY) != 0;
// Adjust all-day times into local timezone
if (allDay) {
final Time recycle = new Time();
start = convertUtcToLocal(recycle, start);
end = convertUtcToLocal(recycle, end);
}
result = getEventFlip(cursor, start, end, allDay);
}
return result;
}
/**
* Calculate flipping point for the given event; when we should hide this
* event and show the next one. This is usually half-way through the event.
* <p>
* Events with duration longer than one day as treated as all-day events
* when computing the flipping point.
*
* @param start Event start time in local timezone.
* @param end Event end time in local timezone.
*/
private long getEventFlip(Cursor cursor, long start, long end, boolean allDay) {
long duration = end - start;
if (allDay || duration > DateUtils.DAY_IN_MILLIS) {
return start;
} else {
return (start + end) / 2;
}
}
/**
* Set visibility of various widget components if there are events, or if no
* events were found.
*
* @param views Set of {@link RemoteViews} to apply visibility.
* @param noEvents True if no events found, otherwise false.
*/
private void setNoEventsVisible(RemoteViews views, boolean noEvents) {
views.setViewVisibility(R.id.no_events, noEvents ? View.VISIBLE : View.GONE);
int otherViews = noEvents ? View.GONE : View.VISIBLE;
views.setViewVisibility(R.id.day_of_month, otherViews);
views.setViewVisibility(R.id.day_of_week, otherViews);
views.setViewVisibility(R.id.divider, otherViews);
views.setViewVisibility(R.id.when, otherViews);
views.setViewVisibility(R.id.title, otherViews);
// Don't force-show views that are handled elsewhere
if (noEvents) {
views.setViewVisibility(R.id.conflict, otherViews);
views.setViewVisibility(R.id.where, otherViews);
}
}
/**
* Build a set of {@link RemoteViews} that describes how to update any
* widget for a specific event instance.
*
* @param cursor Valid cursor on {@link Instances#CONTENT_URI}
* @param events {@link MarkedEvents} parsed from the cursor
*/
private RemoteViews getAppWidgetUpdate(Context context, Cursor cursor, MarkedEvents events) {
Resources res = context.getResources();
ContentResolver resolver = context.getContentResolver();
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.agenda_appwidget);
setNoEventsVisible(views, false);
Time time = new Time();
time.setToNow();
int yearDay = time.yearDay;
int dateNumber = time.monthDay;
// Calendar header
String dayOfWeek = DateUtils.getDayOfWeekString(time.weekDay + 1,
DateUtils.LENGTH_MEDIUM).toUpperCase();
views.setTextViewText(R.id.day_of_week, dayOfWeek);
views.setTextViewText(R.id.day_of_month, Integer.toString(time.monthDay));
// Fill primary event details
cursor.moveToPosition(events.primaryRow);
// Color stripe
/*
int colorFilter = cursor.getInt(INDEX_COLOR);
views.setTextColor(R.id.when, colorFilter);
views.setTextColor(R.id.title, colorFilter);
views.setTextColor(R.id.where, colorFilter);
*/
// When
long start = cursor.getLong(INDEX_BEGIN);
boolean allDay = cursor.getInt(INDEX_ALL_DAY) != 0;
int flags;
String whenString;
if (allDay) {
flags = DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_UTC
| DateUtils.FORMAT_SHOW_DATE;
} else {
flags = DateUtils.FORMAT_ABBREV_ALL | DateUtils.FORMAT_SHOW_TIME;
// Show date if different from today
time.set(start);
if (yearDay != time.yearDay) {
flags = flags | DateUtils.FORMAT_SHOW_DATE;
}
}
if (DateFormat.is24HourFormat(context)) {
flags |= DateUtils.FORMAT_24HOUR;
}
whenString = DateUtils.formatDateRange(context, start, start, flags);
views.setTextViewText(R.id.when, whenString);
// Clicking on the widget launches Calendar
PendingIntent pendingIntent = getLaunchPendingIntent(context, start);
views.setOnClickPendingIntent(R.id.agenda_appwidget, pendingIntent);
// What
String titleString = cursor.getString(INDEX_TITLE);
if (titleString == null || titleString.length() == 0) {
titleString = context.getString(R.string.no_title_label);
}
views.setTextViewText(R.id.title, titleString);
// Conflicts
int titleLines = 4;
if (events.primaryCount > 1) {
int count = events.primaryCount - 1;
String conflictString = String.format(res.getQuantityString(
R.plurals.gadget_more_events, count), count);
views.setTextViewText(R.id.conflict, conflictString);
views.setViewVisibility(R.id.conflict, View.VISIBLE);
titleLines -= 1;
} else {
views.setViewVisibility(R.id.conflict, View.GONE);
}
// Where
String whereString = cursor.getString(INDEX_EVENT_LOCATION);
if (whereString != null && whereString.length() > 0) {
views.setViewVisibility(R.id.where, View.VISIBLE);
views.setTextViewText(R.id.where, whereString);
titleLines -= 1;
} else {
views.setViewVisibility(R.id.where, View.GONE);
}
// Trim title lines based on details shown. In landscape we're using
// singleLine which means this value is ignored.
views.setInt(R.id.title, "setMaxLines", titleLines);
return views;
}
/**
* Build a set of {@link RemoteViews} that describes an error state.
*/
private RemoteViews getAppWidgetNoEvents(Context context) {
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.agenda_appwidget);
setNoEventsVisible(views, true);
// Clicking on widget launches the agenda view in Calendar
PendingIntent pendingIntent = getLaunchPendingIntent(context, 0);
views.setOnClickPendingIntent(R.id.agenda_appwidget, pendingIntent);
return views;
}
/**
* Build a {@link PendingIntent} to launch the Calendar app. This correctly
* sets action, category, and flags so that we don't duplicate tasks when
* Calendar was also launched from a normal desktop icon.
* @param goToTime time that calendar should take the user to
*/
private PendingIntent getLaunchPendingIntent(Context context, long goToTime) {
Intent launchIntent = new Intent();
launchIntent.setComponent(new ComponentName(ACTION_PACKAGE, ACTION_CLASS));
launchIntent.setAction(Intent.ACTION_MAIN);
launchIntent.addCategory(Intent.CATEGORY_LAUNCHER);
launchIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK |
Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED);
if (goToTime != 0) {
launchIntent.putExtra(Calendar.EVENT_BEGIN_TIME, goToTime);
launchIntent.putExtra(KEY_DETAIL_VIEW, true);
}
return PendingIntent.getActivity(context, 0 /* no requestCode */,
launchIntent, PendingIntent.FLAG_UPDATE_CURRENT);
}
private class MarkedEvents {
long primaryTime = -1;
int primaryRow = -1;
int primaryConflictRow = -1;
int primaryCount = 0;
long secondaryTime = -1;
int secondaryRow = -1;
int secondaryCount = 0;
boolean watchFound = false;
}
/**
* Walk the given instances cursor and build a list of marked events to be
* used when updating the widget. This structure is also used to check if
* updates are needed.
*
* @param cursor Valid cursor across {@link Instances#CONTENT_URI}.
* @param watchEventIds Specific events to watch for, setting
* {@link MarkedEvents#watchFound} if found during marking.
* @param now Current system time to use for this update, possibly from
* {@link System#currentTimeMillis()}
*/
private MarkedEvents buildMarkedEvents(Cursor cursor, Set<Long> watchEventIds, long now) {
MarkedEvents events = new MarkedEvents();
final Time recycle = new Time();
cursor.moveToPosition(-1);
while (cursor.moveToNext()) {
int row = cursor.getPosition();
long eventId = cursor.getLong(INDEX_EVENT_ID);
long start = cursor.getLong(INDEX_BEGIN);
long end = cursor.getLong(INDEX_END);
boolean allDay = cursor.getInt(INDEX_ALL_DAY) != 0;
// Adjust all-day times into local timezone
if (allDay) {
start = convertUtcToLocal(recycle, start);
end = convertUtcToLocal(recycle, end);
}
// Skip events that have already passed their flip times
long eventFlip = getEventFlip(cursor, start, end, allDay);
if (LOGD) Log.d(TAG, "Calculated flip time " + formatDebugTime(eventFlip, now));
if (eventFlip < now) {
continue;
}
// Mark if we've encountered the watched event
if (watchEventIds.contains(eventId)) {
events.watchFound = true;
}
if (events.primaryRow == -1) {
// Found first event
events.primaryRow = row;
events.primaryTime = start;
events.primaryCount = 1;
} else if (events.primaryTime == start) {
// Found conflicting primary event
if (events.primaryConflictRow == -1) {
events.primaryConflictRow = row;
}
events.primaryCount += 1;
} else if (events.secondaryRow == -1) {
// Found second event
events.secondaryRow = row;
events.secondaryTime = start;
events.secondaryCount = 1;
} else if (events.secondaryTime == start) {
// Found conflicting secondary event
events.secondaryCount += 1;
} else {
// Nothing interesting about this event, so bail out
break;
}
}
return events;
}
/**
* Query across all calendars for upcoming event instances from now until
* some time in the future.
*
* @param resolver {@link ContentResolver} to use when querying
* {@link Instances#CONTENT_URI}.
* @param searchDuration Distance into the future to look for event
* instances, in milliseconds.
* @param now Current system time to use for this update, possibly from
* {@link System#currentTimeMillis()}.
*/
private Cursor getUpcomingInstancesCursor(ContentResolver resolver,
long searchDuration, long now) {
// Search for events from now until some time in the future
long end = now + searchDuration;
Uri uri = Uri.withAppendedPath(Instances.CONTENT_URI,
String.format("%d/%d", now, end));
String selection = String.format("%s=1 AND %s!=%d",
Calendars.SELECTED, Instances.SELF_ATTENDEE_STATUS,
Attendees.ATTENDEE_STATUS_DECLINED);
return resolver.query(uri, EVENT_PROJECTION, selection, null,
EVENT_SORT_ORDER);
}
}