diff --git a/.github/workflows/dart.yml b/.github/workflows/dart.yml index 7b2c13ae..c6f00747 100644 --- a/.github/workflows/dart.yml +++ b/.github/workflows/dart.yml @@ -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 @@ -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/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 - name: iOS build diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index b836df6a..e2a0b82c 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -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 @@ -34,7 +34,7 @@ jobs: uses: mindsers/changelog-reader-action@v2.0.0 - 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: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35967b42..c94054b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/README.md b/README.md index a91c6e5c..293ae132 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/android/build.gradle b/android/build.gradle index eb0783b7..583f4ead 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -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 } @@ -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' diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 2212bc37..6248397d 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/android/src/main/kotlin/com/builttoroam/devicecalendar/CalendarDelegate.kt b/android/src/main/kotlin/com/builttoroam/devicecalendar/CalendarDelegate.kt index e4e4bec1..9d147abf 100644 --- a/android/src/main/kotlin/com/builttoroam/devicecalendar/CalendarDelegate.kt +++ b/android/src/main/kotlin/com/builttoroam/devicecalendar/CalendarDelegate.kt @@ -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 { @@ -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) } } @@ -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 @@ -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 -> @@ -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 + ) { + 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 )) @@ -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() @@ -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!!) @@ -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!!) @@ -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) { diff --git a/android/src/main/kotlin/com/builttoroam/devicecalendar/DeviceCalendarPlugin.kt b/android/src/main/kotlin/com/builttoroam/devicecalendar/DeviceCalendarPlugin.kt index b1577415..d9c87436 100644 --- a/android/src/main/kotlin/com/builttoroam/devicecalendar/DeviceCalendarPlugin.kt +++ b/android/src/main/kotlin/com/builttoroam/devicecalendar/DeviceCalendarPlugin.kt @@ -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 @@ -179,6 +180,7 @@ class DeviceCalendarPlugin() : FlutterPlugin, MethodCallHandler, ActivityAware { event.eventURL = call.argument(EVENT_URL_ARGUMENT) event.availability = parseAvailability(call.argument(EVENT_AVAILABILITY_ARGUMENT)) event.eventStatus = parseEventStatus(call.argument(EVENT_STATUS_ARGUMENT)) + event.originalInstanceTime = call.argument(EVENT_ORIGINAL_INSTANCE_TIME_ARGUMENT) if (call.hasArgument(RECURRENCE_RULE_ARGUMENT) && call.argument>(RECURRENCE_RULE_ARGUMENT) != null) { val recurrenceRule = parseRecurrenceRuleArgs(call) diff --git a/android/src/main/kotlin/com/builttoroam/devicecalendar/models/Event.kt b/android/src/main/kotlin/com/builttoroam/devicecalendar/models/Event.kt index 67152245..3507f47c 100644 --- a/android/src/main/kotlin/com/builttoroam/devicecalendar/models/Event.kt +++ b/android/src/main/kotlin/com/builttoroam/devicecalendar/models/Event.kt @@ -19,4 +19,5 @@ class Event { var reminders: MutableList = mutableListOf() var availability: Availability? = null var eventStatus: EventStatus? = null + var originalInstanceTime: Long? = null } \ No newline at end of file diff --git a/example/android/app/build.gradle b/example/android/app/build.gradle index 4af82323..39dc2513 100644 --- a/example/android/app/build.gradle +++ b/example/android/app/build.gradle @@ -30,7 +30,7 @@ android { defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.builttoroam.devicecalendarexample" - minSdkVersion 16 + minSdkVersion 21 targetSdkVersion 31 versionCode 1 versionName "1.0" diff --git a/lib/src/models/event.dart b/lib/src/models/event.dart index bc3e2f2d..28216fc1 100644 --- a/lib/src/models/event.dart +++ b/lib/src/models/event.dart @@ -48,7 +48,11 @@ class Event { /// Indicates if this event is of confirmed, canceled, tentative or none status EventStatus? status; - + + /// Write-only. The original instance time of the recurring event for which this event is an exception. + /// Used only on Android for editing recurring events, ignored on iOS + TZDateTime? originalInstanceTime; + ///Note for development: /// ///JSON field names are coded in dart, swift and kotlin to facilitate data exchange. @@ -72,7 +76,8 @@ class Event { this.location, this.url, this.allDay = false, - this.status}); + this.status, + this.originalInstanceTime}); ///Get Event from JSON. /// @@ -202,6 +207,7 @@ class Event { data['eventURL'] = url?.data?.contentText; data['availability'] = availability.enumToString; data['eventStatus'] = status?.enumToString; + data['originalInstanceTime'] = originalInstanceTime?.millisecondsSinceEpoch; if (attendees != null) { data['attendees'] = attendees?.map((a) => a?.toJson()).toList();