blob: ed7456c54c73876b96a7ea207a3aedc2fbb20bdf [file] [log] [blame]
package com.google.android.connecteddevice.calendarsync.android;
import static com.google.android.connecteddevice.calendarsync.android.ContentOwnership.REPLICA;
import static com.google.android.connecteddevice.calendarsync.android.ContentOwnership.SOURCE;
import static com.google.android.connecteddevice.calendarsync.android.FieldTranslator.createBooleanField;
import static com.google.android.connecteddevice.calendarsync.android.FieldTranslator.createInstantField;
import static com.google.android.connecteddevice.calendarsync.android.FieldTranslator.createIntegerField;
import static com.google.android.connecteddevice.calendarsync.android.FieldTranslator.createLongConstant;
import static com.google.android.connecteddevice.calendarsync.android.FieldTranslator.createLongField;
import static com.google.android.connecteddevice.calendarsync.android.FieldTranslator.createStringField;
import static com.google.android.connecteddevice.calendarsync.common.TimeProtoUtil.toInstant;
import static com.google.android.connecteddevice.calendarsync.common.TimeProtoUtil.toTimestamp;
import static com.google.common.collect.Sets.filter;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.database.Cursor;
import android.net.Uri;
import android.provider.CalendarContract.Events;
import android.provider.CalendarContract.Instances;
import androidx.annotation.VisibleForTesting;
import com.google.android.connecteddevice.calendarsync.Color;
import com.google.android.connecteddevice.calendarsync.Event;
import com.google.android.connecteddevice.calendarsync.Event.Status;
import com.google.android.connecteddevice.calendarsync.TimeZone;
import com.google.android.connecteddevice.calendarsync.android.FieldTranslator.ColumnFieldTranslator;
import com.google.android.connecteddevice.calendarsync.common.CommonLogger;
import com.google.android.connecteddevice.calendarsync.common.PlatformContentDelegate;
import com.google.common.base.Joiner;
import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Range;
import com.google.protobuf.MessageLite;
import java.time.Instant;
import java.util.Collection;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
import javax.annotation.Nullable;
/**
* Android specific access to the event content items.
*
* <p>When reading {@link Event}s the {@link android.provider.CalendarContract.Instances} content is
* used so each recurring event can appear more than once e.g. a regular team meeting.
*/
final class EventContentDelegate extends BaseContentDelegate<Event> {
private static final String TAG = "EventContentDelegate";
/**
* A factory for {@link PlatformContentDelegate<Event>}s that allows {@link
* com.google.android.connecteddevice.calendarsync.common.EventManager} to read events in a given
* time range without all the dependencies required by {@link EventContentDelegate}.
*/
static class Factory implements EventContentDelegateFactory {
private final CommonLogger.Factory commonLoggerFactory;
private final ContentResolver resolver;
private final ContentOwnership ownership;
Factory(
CommonLogger.Factory commonLoggerFactory,
ContentResolver resolver,
ContentOwnership ownership) {
this.commonLoggerFactory = commonLoggerFactory;
this.resolver = resolver;
this.ownership = ownership;
}
/**
* Creates a {@link PlatformContentDelegate<Event>} with a given time range.
*
* <p>If the time range is {@code null} then the calendar will be write only and reading will
* through an exception.
*/
@Override
public PlatformContentDelegate<Event> create(@Nullable Range<Instant> timeRange) {
return new EventContentDelegate(commonLoggerFactory, resolver, ownership, timeRange);
}
}
/** The set of fields that are used when writing an event. */
private final ImmutableSet<FieldTranslator<?>> writeEventFields;
private final ImmutableSet<FieldTranslator<?>> writeEventFieldsWithoutEndTime;
private final Uri writeContentUri;
private final ContentOwnership ownership;
EventContentDelegate(
CommonLogger.Factory commonLoggerFactory,
ContentResolver resolver,
ContentOwnership ownership,
Range<Instant> timeRange) {
super(
commonLoggerFactory.create(TAG),
timeRange != null
? createReadInstanceUri(timeRange.lowerEndpoint(), timeRange.upperEndpoint(), ownership)
: null, // No read uri on replica when source sends no time range.
createKeyField(ownership),
createReadInstanceFields(ownership),
resolver,
/* idColumn= */ Instances.EVENT_ID,
/* parentIdColumn= */ Instances.CALENDAR_ID);
writeEventFields = createWriteEventFields(ownership);
writeEventFieldsWithoutEndTime =
ImmutableSet.<FieldTranslator<?>>builder()
.addAll(filter(writeEventFields, field -> !field.getColumns().contains(Events.DTEND)))
.build();
writeContentUri =
ownership == SOURCE ? Events.CONTENT_URI : addSyncAdapterParameters(Events.CONTENT_URI);
this.ownership = ownership;
}
/** Writes to the {@link Events} content rather that {@link Instances} from where data is read. */
@Override
protected Uri getWriteContentUri() {
return writeContentUri;
}
/** Writes to the {@link Events} fields rather that {@link Instances} from where data is read. */
@Override
protected Collection<FieldTranslator<?>> getWriteFields() {
return writeEventFields;
}
@Override
public Object insert(Object calendarId, Event event) {
String key = event.getKey();
if (getRecurrenceTypeFromKey(key) == RecurrenceType.EXCEPTION) {
if (ownership == SOURCE) {
// Exceptions to recurring events are only created on the source.
throw new IllegalStateException("Cannot insert exception on source");
} else {
maybeDeleteRecurringInstance(calendarId, key);
}
}
return super.insert(calendarId, event);
}
/**
* Deletes the recurring event instance on the replica when the same event is returned from the
* source as an exception.
*
* <p>When a change is made to a recurring event instance (with a defined begin time) on the
* replica device it causes a new exception event to be created on the source device. The new
* exception event is sent back to the replica to be created. The original changed recurring event
* instance still exists and needs to be deleted to avoid duplication. The details of the events
* should be the same except that the key will change to now represent the exception event.
*
* <p>Exceptions can also be created on the source directly so there will not always be an
* instance of a recurring event on the replica to delete.
*/
private void maybeDeleteRecurringInstance(Object calendarId, String exceptionEventKey) {
long originalEventId = getOriginalEventIdFromKey(exceptionEventKey);
Instant originalBeginTime = getOriginalBeginTimeFromKey(exceptionEventKey);
String recurringKey = createRecurringKey(originalEventId, originalBeginTime);
// Use super to avoid logic in this class around deleting a recurring instance.
if (super.delete(calendarId, recurringKey)) {
logger.debug("Deleted instance of recurring event %s", recurringKey);
}
}
@Override
public String update(Object calendarId, String key, Event event) {
if (ownership == SOURCE && getRecurrenceTypeFromKey(key) == RecurrenceType.RECURRING) {
return insertUpdateException(key, event);
} else {
return super.update(calendarId, key, event);
}
}
/**
* Inserts an exception to a recurring event with updated values.
*
* <p>Updating a recurring event instance on the replica causes an exception event to be created
* on the source.
*/
private String insertUpdateException(String key, Event event) {
logger.debug("Inserting exception to update recurring event %s", key);
// Instead of updating a recurring event we need to insert an exception event.
insertEventException(key, event);
// Any further changes to the attendees should happen on the exception event.
long originalEventId = getEventIdFromKey(key);
Instant originalBeginTime = getBeginTimeFromKey(key);
return createExceptionKey(originalEventId, originalBeginTime);
}
@Override
public boolean delete(Object calendarId, String key) {
if (ownership == SOURCE && getRecurrenceTypeFromKey(key) == RecurrenceType.RECURRING) {
insertCancelledException(key);
return true;
} else {
return super.delete(calendarId, key);
}
}
/**
* Inserts a cancelled event exception.
*
* <p>Deleted instances of recurring events result in an exception event being created with the
* cancelled status.
*/
private void insertCancelledException(String key) {
logger.debug("Inserting exception to cancel recurring event %s", key);
Uri eventExceptionUri = createEventExceptionContentUri(key);
Instant originalBeginTime = getBeginTimeFromKey(key);
ContentValues values = new ContentValues();
values.put(Events.ORIGINAL_INSTANCE_TIME, originalBeginTime.toEpochMilli());
values.put(Events.STATUS, Events.STATUS_CANCELED);
insertValuesToUri(eventExceptionUri, values);
}
private long insertEventException(String key, Event event) {
// Use a special content uri to insert exceptions to recurring events.
Instant originalBeginTime = getBeginTimeFromKey(key);
Uri eventExceptionUri = createEventExceptionContentUri(key);
// Do not include the end_time field which is not allowed in an exception event.
Set<FieldTranslator<?>> exceptionEventFields = new HashSet<>(writeEventFieldsWithoutEndTime);
exceptionEventFields.add(
createLongConstant(Events.ORIGINAL_INSTANCE_TIME, originalBeginTime.toEpochMilli()));
// Add only the content without the parent ids as they cannot be added to an exception.
ContentValues values = createContentValues(event, exceptionEventFields);
// The calendar provider takes care of duplicating related data such as attendees.
return insertValuesToUri(eventExceptionUri, values);
}
private static Uri createEventExceptionContentUri(String key) {
Uri.Builder exceptionUriBuilder = Events.CONTENT_EXCEPTION_URI.buildUpon();
long originalEventId = getEventIdFromKey(key);
ContentUris.appendId(exceptionUriBuilder, originalEventId);
return exceptionUriBuilder.build();
}
@Override
public Object find(Object calendarId, String key) {
if (ownership == SOURCE && getRecurrenceTypeFromKey(key) == RecurrenceType.RECURRING) {
return duplicateEventAsException(calendarId, key);
} else {
return super.find(calendarId, key);
}
}
/**
* Duplicates the event as an exception so changes to attendees will not affect every instance.
*/
private long duplicateEventAsException(Object calendarId, String key) {
logger.debug("Inserting exception to duplicate recurring event %s", key);
// Make a duplicate of the event as an exception to be updated.
Content<Event> existing = read(calendarId, key);
if (existing == null) {
throw new IllegalStateException("Existing recurring event not found for key " + key);
}
return insertEventException(key, existing.getMessage());
}
@Override
protected MessageLite.Builder createMessageBuilder() {
return Event.newBuilder();
}
/**
* Creates a content {@link Uri} to {@link android.provider.CalendarContract.Instances} for the
* given time range.
*/
private static Uri createReadInstanceUri(Instant start, Instant end, ContentOwnership ownership) {
Uri.Builder builder = Instances.CONTENT_URI.buildUpon();
ContentUris.appendId(builder, start.toEpochMilli());
ContentUris.appendId(builder, end.toEpochMilli());
Uri contentUri = builder.build();
if (ownership == REPLICA) {
contentUri = addSyncAdapterParameters(contentUri);
}
return contentUri;
}
/** Fields to read from {@link android.provider.CalendarContract.Instances}. */
private static ImmutableSet<FieldTranslator<?>> createReadInstanceFields(
ContentOwnership source) {
return ImmutableSet.of(
createKeyField(source),
TITLE,
DESCRIPTION,
LOCATION,
ALL_DAY,
BEGIN_READ,
TIME_ZONE,
createEndTimeField(/* read= */ true),
END_TIMEZONE,
COLOR,
ORGANIZER,
STATUS);
}
/** Creates fields to write to {@link android.provider.CalendarContract.Events}. */
private ImmutableSet<FieldTranslator<?>> createWriteEventFields(ContentOwnership ownership) {
ImmutableSet.Builder<FieldTranslator<?>> builder = ImmutableSet.builder();
builder.add(
TITLE,
DESCRIPTION,
LOCATION,
ALL_DAY,
createBeginTimeField(/* read= */ false),
TIME_ZONE,
createEndTimeField(/* read= */ false),
END_TIMEZONE,
COLOR,
ORGANIZER,
STATUS);
// Only write the key on the replica which is used to find the original event on the source.
if (ownership == REPLICA) {
builder.add(keyField);
}
return builder.build();
}
/**
* The event instance id is not stable so the event id and start time need to be used. Create a
* field for the event identifier that combines eventId and start time.
*/
private static FieldTranslator<String> createKeyField(ContentOwnership ownership) {
return new FieldTranslator<String>(
Event::getKey,
Event.Builder::setKey,
ownership == SOURCE
? ImmutableSet.of(
Instances.EVENT_ID,
Instances.BEGIN,
Instances.RRULE,
Instances.RDATE,
Instances.ORIGINAL_ID,
Instances.ORIGINAL_INSTANCE_TIME)
: ImmutableSet.of(Events._SYNC_ID)) {
@Override
public String get(Cursor cursor) {
if (ownership == SOURCE) {
// Send all the data needed to find the same event if it is changed on the replica.
return createInstanceKeyText(cursor);
} else {
// On the replica, store the source id in a sync adapter field.
return cursor.getString(getColumnIndex(cursor, Events._SYNC_ID));
}
}
@Override
public void set(String value, ContentValues values) {
// Set the exact same values that were used above to find the original event instance.
if (ownership == SOURCE) {
addKeyContentValues(value, values);
} else {
values.put(Events._SYNC_ID, value);
}
}
private String createInstanceKeyText(Cursor cursor) {
if (RRULE.has(cursor) || RDATE.has(cursor)) {
return createRecurringKey(ID.get(cursor), BEGIN_READ.get(cursor));
} else if (ORIGINAL_ID.has(cursor)) {
return createExceptionKey(ORIGINAL_ID.get(cursor), ORIGINAL_BEGIN.get(cursor));
} else {
return createSingleKey(ID.get(cursor));
}
}
};
}
private static final FieldTranslator<String> DESCRIPTION =
createStringField(Events.DESCRIPTION, Event::getDescription, Event.Builder::setDescription);
private static final FieldTranslator<String> TITLE =
createStringField(Events.TITLE, Event::getTitle, Event.Builder::setTitle);
private static final FieldTranslator<String> LOCATION =
createStringField(Events.EVENT_LOCATION, Event::getLocation, Event.Builder::setLocation);
private static final FieldTranslator<String> ORGANIZER =
createStringField(Events.ORGANIZER, Event::getOrganizer, Event.Builder::setOrganizer);
private static final FieldTranslator<Boolean> ALL_DAY =
createBooleanField(Events.ALL_DAY, Event::getIsAllDay, Event.Builder::setIsAllDay);
// Message uses seconds but provider uses millis.
private static FieldTranslator<Instant> createBeginTimeField(boolean read) {
return createInstantField(
read ? Instances.BEGIN : Events.DTSTART,
(Event message) -> message.hasBeginTime() ? toInstant(message.getBeginTime()) : null,
(Event.Builder builder, Instant value) -> builder.setBeginTime(toTimestamp(value)));
}
private static final FieldTranslator<Instant> BEGIN_READ = createBeginTimeField(/* read= */ true);
private static final FieldTranslator<String> TIME_ZONE =
createStringField(
Events.EVENT_TIMEZONE,
(Event message) -> message.hasTimeZone() ? message.getTimeZone().getName() : null,
(Event.Builder builder, String value) ->
builder.setTimeZone(TimeZone.newBuilder().setName(value)));
private static FieldTranslator<Instant> createEndTimeField(boolean read) {
return createInstantField(
read ? Instances.END : Events.DTEND,
(Event message) -> message.hasEndTime() ? toInstant(message.getEndTime()) : null,
(Event.Builder builder, Instant value) -> builder.setEndTime(toTimestamp(value)));
}
private static final FieldTranslator<Integer> COLOR =
createIntegerField(
Events.EVENT_COLOR,
(Event message) -> message.hasColor() ? message.getColor().getArgb() : null,
(Event.Builder builder, Integer value) ->
builder.setColor(Color.newBuilder().setArgb(value)));
private static final FieldTranslator<String> END_TIMEZONE =
createStringField(
Events.EVENT_END_TIMEZONE,
(Event message) -> message.hasEndTimeZone() ? message.getEndTimeZone().getName() : null,
(Event.Builder builder, String value) ->
builder.setEndTimeZone(TimeZone.newBuilder().setName(value)));
private static final FieldTranslator<Integer> STATUS =
createIntegerField(
Events.STATUS,
(Event message) -> statusToInt(message.getStatus()),
(Event.Builder builder, Integer value) -> builder.setStatus(intToStatus(value)));
private static final ColumnFieldTranslator<Long> ID =
createLongField(Instances.EVENT_ID, null, null);
private static final ColumnFieldTranslator<Long> ORIGINAL_ID =
createLongField(Instances.ORIGINAL_ID, null, null);
private static final ColumnFieldTranslator<Instant> ORIGINAL_BEGIN =
createInstantField(Instances.ORIGINAL_INSTANCE_TIME, null, null);
private static final ColumnFieldTranslator<String> RRULE =
createStringField(Instances.RRULE, null, null);
private static final ColumnFieldTranslator<String> RDATE =
createStringField(Instances.RDATE, null, null);
/** The type of event regarding recurrence. */
private enum RecurrenceType {
/** An event that only occurs once. */
SINGLE("S"),
/** An event that recurs and can have multiple instances with different begin times. */
RECURRING("R"),
/**
* An exception to a recurring event is any change to a recurring event such as a change to a
* regular meeting.
*/
EXCEPTION("X");
/** The code to include in the key. */
final String code;
RecurrenceType(String code) {
this.code = code;
}
/**
* Find the {@link RecurrenceType} with the given code or throw an exception if it is invalid.
*/
public static RecurrenceType fromCode(String code) {
for (RecurrenceType candidate : RecurrenceType.values()) {
if (candidate.code.equals(code)) {
return candidate;
}
}
throw new IllegalArgumentException("Unknown recurrence code: " + code);
}
}
private static Status intToStatus(int status) {
switch (status) {
case Instances.STATUS_CANCELED:
return Status.CANCELED;
case Instances.STATUS_CONFIRMED:
return Status.CONFIRMED;
case Instances.STATUS_TENTATIVE:
return Status.TENTATIVE;
default:
return Status.UNSPECIFIED_STATUS;
}
}
@Nullable
private static Integer statusToInt(Status status) {
switch (status) {
case CANCELED:
return Instances.STATUS_CANCELED;
case CONFIRMED:
return Instances.STATUS_CONFIRMED;
case TENTATIVE:
return Instances.STATUS_TENTATIVE;
default:
// A null value will not be set in the Cursor as there is no valid "unspecified" value.
return null;
}
}
private static final String KEY_SEPARATOR = ":";
private static final Joiner KEY_JOINER = Joiner.on(KEY_SEPARATOR);
private static final Splitter KEY_SPLITTER = Splitter.on(KEY_SEPARATOR);
/** Create a key for a non-recurring event which only requires the event id to identify it. */
@VisibleForTesting
static String createSingleKey(long id) {
return KEY_JOINER.join(RecurrenceType.SINGLE.code, id);
}
/**
* Create a key for a recurring event instance using the begin time. The time is required to
* differentiate this occurrence from others.
*/
@VisibleForTesting
static String createRecurringKey(long id, Instant beginTime) {
return KEY_JOINER.join(RecurrenceType.RECURRING.code, id, beginTime.toEpochMilli());
}
/**
* Create a key for an exception to a recurring event using the original event begin time and id.
* They are required for creating a new exception to replace this one if it is modified.
*/
@VisibleForTesting
static String createExceptionKey(long originalId, Instant originalBeginTime) {
return KEY_JOINER.join(
RecurrenceType.EXCEPTION.code, originalId, originalBeginTime.toEpochMilli());
}
private static RecurrenceType addKeyContentValues(String value, ContentValues values) {
Iterator<String> parts = KEY_SPLITTER.split(value).iterator();
RecurrenceType recurrenceType = RecurrenceType.fromCode(parts.next());
switch (recurrenceType) {
case SINGLE:
ID.set(Long.parseLong(parts.next()), values);
break;
case EXCEPTION:
ORIGINAL_ID.set(Long.parseLong(parts.next()), values);
ORIGINAL_BEGIN.set(Instant.ofEpochMilli(Long.parseLong(parts.next())), values);
break;
case RECURRING:
ID.set(Long.parseLong(parts.next()), values);
BEGIN_READ.set(Instant.ofEpochMilli(Long.parseLong(parts.next())), values);
break;
}
return recurrenceType;
}
private static RecurrenceType getRecurrenceTypeFromKey(String key) {
ContentValues values = new ContentValues();
return addKeyContentValues(key, values);
}
private static Instant getBeginTimeFromKey(String key) {
ContentValues values = new ContentValues();
addKeyContentValues(key, values);
return Instant.ofEpochMilli(values.getAsLong(Instances.BEGIN));
}
private static long getEventIdFromKey(String key) {
ContentValues values = new ContentValues();
addKeyContentValues(key, values);
return values.getAsLong(Instances.EVENT_ID);
}
private static long getOriginalEventIdFromKey(String key) {
ContentValues values = new ContentValues();
addKeyContentValues(key, values);
return values.getAsLong(Instances.ORIGINAL_ID);
}
private static Instant getOriginalBeginTimeFromKey(String key) {
ContentValues values = new ContentValues();
addKeyContentValues(key, values);
return Instant.ofEpochMilli(values.getAsLong(Instances.ORIGINAL_INSTANCE_TIME));
}
}