Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Android recurring events, edit single instance #428

Open
wants to merge 6 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions .github/workflows/dart.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ jobs:
name: Build test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v1
- uses: actions/checkout@v3.0.2
- uses: subosito/flutter-action@v2.4.0
with:
channel: "stable"
cache: true
cache-key: flutter-android # optional, change this to force refresh cache
- run: dart --version
- run: flutter --version
- run: flutter test
Expand All @@ -36,13 +38,12 @@ jobs:
name: iOS build test
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-java@v1
with:
java-version: "12.x"
- uses: subosito/flutter-action@v1
- uses: actions/[email protected]
- uses: subosito/[email protected]
with:
channel: "stable"
cache: true
cache-key: flutter-android # optional, change this to force refresh cache
- run: dart --version
- run: flutter --version
- name: iOS build
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/prerelease.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v1
- uses: actions/checkout@v3.0.2
- uses: subosito/flutter-action@v2.4.0
with:
channel: "stable"
- run: dart --version
Expand All @@ -34,7 +34,7 @@ jobs:
uses: mindsers/[email protected]
- name: Edit changelog.md for dev release
run: |
sed -i "0,/\#\# \[.*/s//## [${{steps.changelog_reader.outputs.version}}-$GITHUB_RUN_ID]/" CHANGELOG.md
sed -i "0,/\#\# \[.*/s//## [${{steps.changelog_reader.outputs.version}}+$GITHUB_RUN_ID]/" CHANGELOG.md
cat CHANGELOG.md
- name: Setup credentials
run: |
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v1
- uses: actions/checkout@v3.0.2
- uses: subosito/flutter-action@v2.4.0
with:
channel: "stable"
- run: dart --version
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ A cross platform plugin for modifying calendars on the user's device.
* Retrieve events associated with a calendar
* Add, update or delete events from a calendar
* Set up, edit or delete recurring events
* **NOTE**: Editing a recurring event will currently edit all instances of it
* **NOTE**: Deleting multiple instances in **Android** takes time to update, you'll see the changes after a few seconds
* Add, modify or remove attendees and receive if an attendee is an organiser for an event
* Setup reminders for an event
Expand Down Expand Up @@ -59,6 +58,12 @@ Future setCurentLocation() async {
event.start = TZDateTime.from(oldDateTime, _currentLocation);
```

### Editing single instances of recurring events

To edit a single instance of the recurring event on Android, set the `originalInstanceTime`
field of an `Event`. If an original event is recognized as recurring, an exception is created or updated.
Otherwise the entire event is created or updated.

For other use cases, feedback or future developments on the feature, feel free to open a discussion on GitHub.

## Null-safety migration
Expand Down
6 changes: 4 additions & 2 deletions android/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,20 @@ apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'

android {
compileSdkVersion 30
compileSdkVersion 32

sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
defaultConfig {
minSdkVersion 16
minSdkVersion 21
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
lintOptions {
disable 'InvalidPackage'
}
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
Expand All @@ -48,6 +49,7 @@ android {
}

dependencies {
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.google.code.gson:gson:2.8.8'
api 'androidx.appcompat:appcompat:1.3.1'
Expand Down
2 changes: 1 addition & 1 deletion android/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ import org.dmfs.rfc5545.DateTime
import org.dmfs.rfc5545.Weekday
import org.dmfs.rfc5545.recur.Freq
import java.text.SimpleDateFormat
import java.time.Duration
import java.time.Instant
import java.util.*

class CalendarDelegate : PluginRegistry.RequestPermissionsResultListener {
Expand Down Expand Up @@ -150,7 +152,7 @@ class CalendarDelegate : PluginRegistry.RequestPermissionsResultListener {
finishWithSuccess(permissionGranted, cachedValues.pendingChannelResult)
}
DELETE_CALENDAR_REQUEST_CODE -> {
deleteCalendar(cachedValues.calendarId,cachedValues.pendingChannelResult)
deleteCalendar(cachedValues.calendarId, cachedValues.pendingChannelResult)
}
}

Expand Down Expand Up @@ -259,7 +261,7 @@ class CalendarDelegate : PluginRegistry.RequestPermissionsResultListener {

val contentResolver: ContentResolver? = _context?.contentResolver

val calendar = retrieveCalendar(calendarId,pendingChannelResult,true);
val calendar = retrieveCalendar(calendarId,pendingChannelResult,true)
if(calendar != null) {
val calenderUriWithId = ContentUris.withAppendedId(CalendarContract.Calendars.CONTENT_URI, calendarIdNumber)
val deleteSucceeded = contentResolver?.delete(calenderUriWithId, null, null) ?: 0
Expand Down Expand Up @@ -386,7 +388,7 @@ class CalendarDelegate : PluginRegistry.RequestPermissionsResultListener {
return
}

val contentResolver: ContentResolver? = _context?.contentResolver
val contentResolver: ContentResolver = _context?.contentResolver ?: return
val values = buildEventContentValues(event, calendarId)

val exceptionHandler = CoroutineExceptionHandler { _, exception ->
Expand All @@ -396,58 +398,142 @@ class CalendarDelegate : PluginRegistry.RequestPermissionsResultListener {
}

val job: Job
var eventId: Long? = event.eventId?.toLongOrNull()
if (eventId == null) {
val uri = contentResolver?.insert(Events.CONTENT_URI, values)
val existingEventId: Long? = event.eventId?.toLongOrNull()
if (existingEventId == null) {
val uri = contentResolver.insert(Events.CONTENT_URI, values)
// get the event ID that is the last element in the Uri
eventId = java.lang.Long.parseLong(uri?.lastPathSegment!!)
val eventId = java.lang.Long.parseLong(uri?.lastPathSegment!!)
job = GlobalScope.launch(Dispatchers.IO + exceptionHandler) {
insertAttendees(event.attendees, eventId, contentResolver)
insertReminders(event.reminders, eventId, contentResolver)
}
} else {
job = GlobalScope.launch(Dispatchers.IO + exceptionHandler) {
contentResolver?.update(ContentUris.withAppendedId(Events.CONTENT_URI, eventId), values, null, null)
val existingAttendees = retrieveAttendees(calendar, eventId.toString(), contentResolver)
val attendeesToDelete = if (event.attendees.isNotEmpty()) existingAttendees.filter { existingAttendee -> event.attendees.all { it.emailAddress != existingAttendee.emailAddress } } else existingAttendees
for (attendeeToDelete in attendeesToDelete) {
deleteAttendee(eventId, attendeeToDelete, contentResolver)
val originalSyncId = when (event.originalInstanceTime == null) {
true -> null
false -> getOriginalSyncId(contentResolver, existingEventId)
}

val attendeesToInsert = event.attendees.filter { existingAttendees.all { existingAttendee -> existingAttendee.emailAddress != it.emailAddress } }
insertAttendees(attendeesToInsert, eventId, contentResolver)
deleteExistingReminders(contentResolver, eventId)
insertReminders(event.reminders, eventId, contentResolver!!)

val existingSelfAttendee = existingAttendees.firstOrNull {
it.emailAddress == calendar.ownerAccount
}
val newSelfAttendee = event.attendees.firstOrNull {
it.emailAddress == calendar.ownerAccount
}
if (existingSelfAttendee != null && newSelfAttendee != null &&
newSelfAttendee.attendanceStatus != null &&
existingSelfAttendee.attendanceStatus != newSelfAttendee.attendanceStatus) {
updateAttendeeStatus(eventId, newSelfAttendee, contentResolver)
if (originalSyncId != null) {
val instanceValues = ContentValues()
instanceValues.put(Events.DTSTART, event.eventStartDate)
instanceValues.put(
Events.DURATION,
Duration.between(
Instant.ofEpochMilli(event.originalInstanceTime!!),
Instant.ofEpochMilli(event.eventEndDate!!)
).toString()
)
instanceValues.put(Events.ALL_DAY, event.eventAllDay)
instanceValues.put(Events.TITLE, event.eventTitle)
instanceValues.put(Events.DESCRIPTION, event.eventDescription)
instanceValues.put(Events.EVENT_LOCATION, event.eventLocation)

instanceValues.put(Events.ORIGINAL_SYNC_ID, originalSyncId)
instanceValues.put(Events.ORIGINAL_INSTANCE_TIME, event.originalInstanceTime)
val uri = contentResolver.insert(
ContentUris.withAppendedId(
Events.CONTENT_EXCEPTION_URI,
existingEventId
), instanceValues
)
val exceptionEventId = java.lang.Long.parseLong(uri?.lastPathSegment!!)
updateAttendees(
calendar,
exceptionEventId,
contentResolver,
event.attendees
)
updateReminders(contentResolver, exceptionEventId, event)
return@launch
} else {
contentResolver.update(
ContentUris.withAppendedId(
Events.CONTENT_URI,
existingEventId
), values, null, null
)
}
updateAttendees(calendar, existingEventId, contentResolver, event.attendees)
updateReminders(contentResolver, existingEventId, event)
}
}
job.invokeOnCompletion {
cause ->
job.invokeOnCompletion { cause ->
if (cause == null) {
uiThreadHandler.post {
finishWithSuccess(eventId.toString(), pendingChannelResult)
finishWithSuccess(existingEventId.toString(), pendingChannelResult)
}
}
}
} else {
val parameters = CalendarMethodsParametersCacheModel(pendingChannelResult, CREATE_OR_UPDATE_EVENT_REQUEST_CODE, calendarId)
val parameters = CalendarMethodsParametersCacheModel(
pendingChannelResult,
CREATE_OR_UPDATE_EVENT_REQUEST_CODE,
calendarId
)
parameters.event = event
requestPermissions(parameters)
}
}

private fun deleteExistingReminders(contentResolver: ContentResolver?, eventId: Long) {
private fun updateReminders(
contentResolver: ContentResolver,
eventId: Long,
event: Event
) {
deleteExistingReminders(contentResolver, eventId)
insertReminders(event.reminders, eventId, contentResolver)
}

private fun getOriginalSyncId(
contentResolver: ContentResolver,
existingEventId: Long
) = contentResolver.query(
ContentUris.withAppendedId(
Events.CONTENT_URI,
existingEventId
), arrayOf(Events.RRULE, Events._SYNC_ID), null, null, null
)?.use {
if (it.moveToFirst() && it.getString(it.getColumnIndexOrThrow(Events.RRULE)) != null) {
it.getString(it.getColumnIndexOrThrow(Events._SYNC_ID))
} else {
null
}
}

private fun updateAttendees(
calendar: Calendar,
eventId: Long,
contentResolver: ContentResolver,
attendees: List<Attendee>
) {
val existingAttendees =
retrieveAttendees(calendar, eventId.toString(), contentResolver)
val attendeesToDelete =
if (attendees.isNotEmpty()) existingAttendees.filter { existingAttendee -> attendees.all { it.emailAddress != existingAttendee.emailAddress } } else existingAttendees
for (attendeeToDelete in attendeesToDelete) {
deleteAttendee(eventId, attendeeToDelete, contentResolver)
}

val attendeesToInsert =
attendees.filter { existingAttendees.all { existingAttendee -> existingAttendee.emailAddress != it.emailAddress } }
insertAttendees(attendeesToInsert, eventId, contentResolver)

val existingSelfAttendee = existingAttendees.firstOrNull {
it.emailAddress == calendar.ownerAccount
}
val newSelfAttendee = attendees.firstOrNull {
it.emailAddress == calendar.ownerAccount
}
if (existingSelfAttendee != null && newSelfAttendee != null &&
newSelfAttendee.attendanceStatus != null &&
existingSelfAttendee.attendanceStatus != newSelfAttendee.attendanceStatus
) {
updateAttendeeStatus(eventId, newSelfAttendee, contentResolver)
}
}

private fun deleteExistingReminders(contentResolver: ContentResolver, eventId: Long) {
val cursor = CalendarContract.Reminders.query(contentResolver, eventId, arrayOf(
CalendarContract.Reminders._ID
))
Expand All @@ -458,7 +544,7 @@ class CalendarDelegate : PluginRegistry.RequestPermissionsResultListener {
reminderUri = ContentUris.withAppendedId(CalendarContract.Reminders.CONTENT_URI, reminderId)
}
if (reminderUri != null) {
contentResolver?.delete(reminderUri, null, null)
contentResolver.delete(reminderUri, null, null)
}
}
cursor?.close()
Expand All @@ -482,7 +568,7 @@ class CalendarDelegate : PluginRegistry.RequestPermissionsResultListener {
private fun buildEventContentValues(event: Event, calendarId: String): ContentValues {
val values = ContentValues()
val duration: String? = null
values.put(Events.ALL_DAY, event.eventAllDay)
values.put(Events.ALL_DAY, if (event.eventAllDay) 1 else 0)
values.put(Events.DTSTART, event.eventStartDate!!)
values.put(Events.EVENT_TIMEZONE, getTimeZone(event.eventStartTimeZone).id)
values.put(Events.DTEND, event.eventEndDate!!)
Expand All @@ -493,8 +579,12 @@ class CalendarDelegate : PluginRegistry.RequestPermissionsResultListener {
values.put(Events.CUSTOM_APP_URI, event.eventURL)
values.put(Events.CALENDAR_ID, calendarId)
values.put(Events.DURATION, duration)
values.put(Events.AVAILABILITY, getAvailability(event.availability))
values.put(Events.STATUS, getEventStatus(event.eventStatus))
val availability = getAvailability(event.availability)
if (availability != null)
values.put(Events.AVAILABILITY, availability)
val eventStatus = getEventStatus(event.eventStatus)
if (eventStatus != null)
values.put(Events.STATUS, eventStatus)

if (event.recurrenceRule != null) {
val recurrenceRuleParams = buildRecurrenceRuleParams(event.recurrenceRule!!)
Expand Down Expand Up @@ -563,12 +653,12 @@ class CalendarDelegate : PluginRegistry.RequestPermissionsResultListener {

}

private fun updateAttendeeStatus(eventId: Long, attendee: Attendee, contentResolver: ContentResolver?) {
private fun updateAttendeeStatus(eventId: Long, attendee: Attendee, contentResolver: ContentResolver) {
val selection = "(" + CalendarContract.Attendees.EVENT_ID + " = ?) AND (" + CalendarContract.Attendees.ATTENDEE_EMAIL + " = ?)"
val selectionArgs = arrayOf(eventId.toString() + "", attendee.emailAddress)
val values = ContentValues()
values.put(CalendarContract.Attendees.ATTENDEE_STATUS, attendee.attendanceStatus)
contentResolver?.update(CalendarContract.Attendees.CONTENT_URI, values, selection, selectionArgs)
contentResolver.update(CalendarContract.Attendees.CONTENT_URI, values, selection, selectionArgs)
}

fun deleteEvent(calendarId: String, eventId: String, pendingChannelResult: MethodChannel.Result, startDate: Long? = null, endDate: Long? = null, followingInstances: Boolean? = null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class DeviceCalendarPlugin() : FlutterPlugin, MethodCallHandler, ActivityAware {
private val EVENT_AVAILABILITY_ARGUMENT = "availability"
private val ATTENDANCE_STATUS_ARGUMENT = "attendanceStatus"
private val EVENT_STATUS_ARGUMENT = "eventStatus"
private val EVENT_ORIGINAL_INSTANCE_TIME_ARGUMENT = "originalInstanceTime"

private lateinit var _calendarDelegate: CalendarDelegate

Expand Down Expand Up @@ -179,6 +180,7 @@ class DeviceCalendarPlugin() : FlutterPlugin, MethodCallHandler, ActivityAware {
event.eventURL = call.argument<String>(EVENT_URL_ARGUMENT)
event.availability = parseAvailability(call.argument<String>(EVENT_AVAILABILITY_ARGUMENT))
event.eventStatus = parseEventStatus(call.argument<String>(EVENT_STATUS_ARGUMENT))
event.originalInstanceTime = call.argument<Long>(EVENT_ORIGINAL_INSTANCE_TIME_ARGUMENT)

if (call.hasArgument(RECURRENCE_RULE_ARGUMENT) && call.argument<Map<String, Any>>(RECURRENCE_RULE_ARGUMENT) != null) {
val recurrenceRule = parseRecurrenceRuleArgs(call)
Expand Down
Loading