diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt index 4d206b6c..e88f456a 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt @@ -8,37 +8,28 @@ package at.bitfire.ical4android import android.accounts.Account import android.content.ContentProviderClient import android.content.ContentUris -import android.os.Build import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL import android.provider.CalendarContract.AUTHORITY import android.provider.CalendarContract.Events import androidx.core.content.contentValuesOf import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import at.bitfire.ical4android.impl.TestCalendar -import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter import at.bitfire.ical4android.util.MiscUtils.closeCompat import at.bitfire.synctools.icalendar.Css3Color import at.bitfire.synctools.storage.calendar.AndroidCalendar import at.bitfire.synctools.test.InitCalendarProviderRule -import net.fortuna.ical4j.model.Date -import net.fortuna.ical4j.model.DateList import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.parameter.Value import net.fortuna.ical4j.model.property.Attendee import net.fortuna.ical4j.model.property.DtEnd import net.fortuna.ical4j.model.property.DtStart -import net.fortuna.ical4j.model.property.ExDate import net.fortuna.ical4j.model.property.Organizer -import net.fortuna.ical4j.model.property.RRule -import net.fortuna.ical4j.model.property.RecurrenceId import net.fortuna.ical4j.model.property.Status import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -61,7 +52,7 @@ class AndroidEventTest { val context = getInstrumentation().targetContext client = context.contentResolver.acquireContentProviderClient(AUTHORITY)!! - calendar = TestCalendar.findOrCreate(testAccount, client) + calendar = TestCalendar.findOrCreate(testAccount, client, withColors = true) } @After @@ -209,7 +200,6 @@ class AndroidEventTest { } - @Test fun testTransaction() { val event = Event() @@ -229,240 +219,4 @@ class AndroidEventTest { } } - - // companion object - - @Test - fun testMarkEventAsDeleted() { - // Create event - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "A fine event" - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - // Delete event - AndroidEvent.markAsDeleted(client, testAccount, localEvent.id!!) - - // Get the status of whether the event is deleted - client.query( - ContentUris.withAppendedId(Events.CONTENT_URI, localEvent.id!!).asSyncAdapter(testAccount), - arrayOf(Events.DELETED), - null, - null, null - )!!.use { cursor -> - cursor.moveToFirst() - assertEquals(1, cursor.getInt(0)) - } - } - - - @Test - fun testNumDirectInstances_SingleInstance() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 1 instance" - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertEquals(1, AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumDirectInstances_Recurring() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 5 instances" - rRules.add(RRule("FREQ=DAILY;COUNT=5")) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertEquals(5, AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumDirectInstances_Recurring_Endless() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event without end" - rRules.add(RRule("FREQ=DAILY")) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertNull(AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - } - - @Test - // flaky, needs InitCalendarProviderRule - fun testNumDirectInstances_Recurring_LateEnd() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 53 years" - rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 is not supported by Android <11 Calendar Storage - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) - assertEquals(52, AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - else - assertNull(AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumDirectInstances_Recurring_ManyInstances() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 2 years" - rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z")) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - val number = AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!) - - // Some android versions (i.e. <=Q and S) return 365*2 instances (wrong, 365*2+1 => correct), - // but we are satisfied with either result for now - assertTrue(number == 365 * 2 || number == 365 * 2 + 1) - } - - @Test - fun testNumDirectInstances_RecurringWithExdate() { - val event = Event().apply { - dtStart = DtStart(Date("20220120T010203Z")) - summary = "Event with 5 instances" - rRules.add(RRule("FREQ=DAILY;COUNT=5")) - exDates.add(ExDate(DateList("20220121T010203Z", Value.DATE_TIME))) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertEquals(4, AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumDirectInstances_RecurringWithExceptions() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 5 instances" - rRules.add(RRule("FREQ=DAILY;COUNT=5")) - exceptions.add(Event().apply { - recurrenceId = RecurrenceId("20220122T010203Z") - dtStart = DtStart("20220122T130203Z") - summary = "Exception on 3rd day" - }) - exceptions.add(Event().apply { - recurrenceId = RecurrenceId("20220124T010203Z") - dtStart = DtStart("20220122T160203Z") - summary = "Exception on 5th day" - }) - } - val localEvent = AndroidEvent(calendar, event, "filename.ics", null, null, 0) - localEvent.add() - - assertEquals(5 - 2, AndroidEvent.numDirectInstances(client, testAccount, localEvent.id!!)) - } - - - @Test - fun testNumInstances_SingleInstance() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 1 instance" - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertEquals(1, AndroidEvent.numInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumInstances_Recurring() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 5 instances" - rRules.add(RRule("FREQ=DAILY;COUNT=5")) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertEquals(5, AndroidEvent.numInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumInstances_Recurring_Endless() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with infinite instances" - rRules.add(RRule("FREQ=YEARLY")) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertNull(AndroidEvent.numInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumInstances_Recurring_LateEnd() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event over 22 years" - rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 not supported by Android <11 Calendar Storage - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) - assertEquals(52, AndroidEvent.numInstances(client, testAccount, localEvent.id!!)) - else - assertNull(AndroidEvent.numInstances(client, testAccount, localEvent.id!!)) - } - - @Test - fun testNumInstances_Recurring_ManyInstances() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event over two years" - rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z")) - } - val localEvent = AndroidEvent(calendar, event, null, null, null, 0) - localEvent.add() - - assertEquals( - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) - 365 * 2 // Android <9: does not include UNTIL (incorrect!) - else - 365 * 2 + 1, // Android ≥10: includes UNTIL (correct) - AndroidEvent.numInstances(client, testAccount, localEvent.id!!) - ) - } - - @Test - fun testNumInstances_RecurringWithExceptions() { - val event = Event().apply { - dtStart = DtStart("20220120T010203Z") - summary = "Event with 6 instances" - rRules.add(RRule("FREQ=DAILY;COUNT=6")) - exceptions.add(Event().apply { - recurrenceId = RecurrenceId("20220122T010203Z") - dtStart = DtStart("20220122T130203Z") - summary = "Exception on 3rd day" - }) - exceptions.add(Event().apply { - recurrenceId = RecurrenceId("20220124T010203Z") - dtStart = DtStart("20220122T160203Z") - summary = "Exception on 5th day" - }) - } - val localEvent = AndroidEvent(calendar, event, "filename.ics", null, null, 0) - localEvent.add() - - calendar.getEvent(localEvent.id!!)!! - - assertEquals(6, AndroidEvent.numInstances(client, testAccount, localEvent.id!!)) - } - } \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt index ee54ef70..54ef7742 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt @@ -17,11 +17,14 @@ import java.util.UUID object TestCalendar { - fun findOrCreate(account: Account, client: ContentProviderClient): AndroidCalendar { + fun findOrCreate(account: Account, client: ContentProviderClient, withColors: Boolean = false): AndroidCalendar { val provider = AndroidCalendarProvider(account, client) // we use colors for testing - provider.provideCss3ColorIndices() + if (withColors) + provider.provideCss3ColorIndices() + else + provider.removeColorIndices() return provider.findFirstCalendar( null, null) ?: provider.createAndGetCalendar(contentValuesOf( diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarProviderTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarProviderTest.kt new file mode 100644 index 00000000..136bc4bb --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarProviderTest.kt @@ -0,0 +1,113 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.synctools.storage.calendar + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.provider.CalendarContract +import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL +import android.provider.CalendarContract.Calendars +import androidx.core.content.contentValuesOf +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.ical4android.AndroidEvent +import at.bitfire.ical4android.Event +import at.bitfire.ical4android.impl.TestCalendar +import at.bitfire.ical4android.util.MiscUtils.closeCompat +import at.bitfire.synctools.icalendar.Css3Color +import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.property.DtStart +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +class AndroidCalendarProviderTest { + + @get:Rule + val permissonRule = GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR) + + private val testAccount = Account(javaClass.name, ACCOUNT_TYPE_LOCAL) + + lateinit var client: ContentProviderClient + lateinit var provider: AndroidCalendarProvider + + @Before + fun setUp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! + provider = AndroidCalendarProvider(testAccount, client) + } + + @After + fun tearDown() { + client.closeCompat() + } + + + @Test + fun testCreateAndGetCalendar() { + // create calendar + val calendar = provider.createAndGetCalendar( + contentValuesOf( + Calendars.NAME to "TestCalendar", + Calendars.CALENDAR_DISPLAY_NAME to "ical4android Test Calendar", + Calendars.VISIBLE to 0, + Calendars.SYNC_EVENTS to 0 + ) + ) + + // delete calendar + assertEquals(1, calendar.delete()) + } + + + @Test + fun testProvideCss3Colors() { + provider.provideCss3ColorIndices() + assertEquals(Css3Color.entries.size, countColors()) + } + + @Test + fun testInsertColors_AlreadyThere() { + provider.provideCss3ColorIndices() + provider.provideCss3ColorIndices() + assertEquals(Css3Color.entries.size, countColors()) + } + + @Test + fun testRemoveCss3Colors() { + provider.provideCss3ColorIndices() + + // insert an event with that color + val cal = TestCalendar.findOrCreate(testAccount, client) + try { + // add event with color + AndroidEvent(cal, Event().apply { + dtStart = DtStart("20210314T204200Z") + dtEnd = DtEnd("20210314T204230Z") + color = Css3Color.limegreen + summary = "Test event with color" + }, "remove-colors").add() + + provider.removeColorIndices() + assertEquals(0, countColors()) + } finally { + cal.delete() + } + } + + private fun countColors(): Int { + client.query(provider.colorsUri, null, null, null, null)!!.use { cursor -> + cursor.moveToNext() + return cursor.count + } + } + +} \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarTest.kt index 160c1e23..0a75be16 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendarTest.kt @@ -9,21 +9,25 @@ package at.bitfire.synctools.storage.calendar import android.Manifest import android.accounts.Account import android.content.ContentProviderClient +import android.os.Build import android.provider.CalendarContract -import android.provider.CalendarContract.Calendars -import androidx.core.content.contentValuesOf import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import at.bitfire.ical4android.AndroidEvent import at.bitfire.ical4android.Event import at.bitfire.ical4android.impl.TestCalendar import at.bitfire.ical4android.util.MiscUtils.closeCompat -import at.bitfire.synctools.icalendar.Css3Color -import net.fortuna.ical4j.model.TimeZoneRegistryFactory -import net.fortuna.ical4j.model.property.DtEnd +import net.fortuna.ical4j.model.Date +import net.fortuna.ical4j.model.DateList +import net.fortuna.ical4j.model.parameter.Value import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RecurrenceId import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -36,85 +40,235 @@ class AndroidCalendarTest { Manifest.permission.WRITE_CALENDAR ) - private val testAccount = Account("ical4android.AndroidCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL) - private val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() + private val testAccount = Account(javaClass.name, CalendarContract.ACCOUNT_TYPE_LOCAL) lateinit var client: ContentProviderClient lateinit var provider: AndroidCalendarProvider + lateinit var calendar: AndroidCalendar + @Before fun prepare() { client = InstrumentationRegistry.getInstrumentation().targetContext.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY)!! // make sure there are no colors for testAccount provider = AndroidCalendarProvider(testAccount, client) - provider.removeColorIndices() - assertEquals(0, countColors()) + calendar = TestCalendar.findOrCreate(testAccount, client) } @After fun tearDown() { + calendar.delete() client.closeCompat() } + // event instances + @Test - fun testCreateAndGetCalendar() { - // create calendar - val calendar = provider.createAndGetCalendar( - contentValuesOf( - Calendars.NAME to "TestCalendar", - Calendars.CALENDAR_DISPLAY_NAME to "ical4android Test Calendar", - Calendars.VISIBLE to 0, - Calendars.SYNC_EVENTS to 0 - ) - ) + fun testNumDirectInstances_SingleInstance() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 1 instance" + } + val localEvent = AndroidEvent(calendar, event, null, null, null, 0) + localEvent.add() - // delete calendar - assertEquals(1, calendar.delete()) + assertEquals(1, calendar.numDirectInstances(localEvent.id!!)) } + @Test + fun testNumDirectInstances_Recurring() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 5 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=5")) + } + val localEvent = AndroidEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertEquals(5, calendar.numDirectInstances(localEvent.id!!)) + } @Test - fun testProvideCss3Colors() { - provider.provideCss3ColorIndices() - assertEquals(Css3Color.entries.size, countColors()) + fun testNumDirectInstances_Recurring_Endless() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event without end" + rRules.add(RRule("FREQ=DAILY")) + } + val localEvent = AndroidEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertNull(calendar.numDirectInstances(localEvent.id!!)) } @Test - fun testInsertColors_AlreadyThere() { - provider.provideCss3ColorIndices() - provider.provideCss3ColorIndices() - assertEquals(Css3Color.entries.size, countColors()) + fun testNumDirectInstances_Recurring_LateEnd() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 53 years" + rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 is not supported by Android <11 Calendar Storage + } + val localEvent = AndroidEvent(calendar, event, null, null, null, 0) + localEvent.add() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + assertEquals(52, calendar.numDirectInstances(localEvent.id!!)) + else + assertNull(calendar.numDirectInstances(localEvent.id!!)) } @Test - fun testRemoveCss3Colors() { - provider.provideCss3ColorIndices() - - // insert an event with that color - val cal = TestCalendar.findOrCreate(testAccount, client) - try { - // add event with color - AndroidEvent(cal, Event().apply { - dtStart = DtStart("20210314T204200Z") - dtEnd = DtEnd("20210314T204230Z") - color = Css3Color.limegreen - summary = "Test event with color" - }, "remove-colors").add() - - provider.removeColorIndices() - assertEquals(0, countColors()) - } finally { - cal.delete() + fun testNumDirectInstances_Recurring_ManyInstances() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 2 years" + rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z")) } + val localEvent = AndroidEvent(calendar, event, null, null, null, 0) + localEvent.add() + val number = calendar.numDirectInstances(localEvent.id!!) + + // Some android versions (i.e. <=Q and S) return 365*2 instances (wrong, 365*2+1 => correct), + // but we are satisfied with either result for now + assertTrue(number == 365 * 2 || number == 365 * 2 + 1) } - private fun countColors(): Int { - client.query(provider.colorsUri, null, null, null, null)!!.use { cursor -> - cursor.moveToNext() - return cursor.count + @Test + fun testNumDirectInstances_RecurringWithExdate() { + val event = Event().apply { + dtStart = DtStart(Date("20220120T010203Z")) + summary = "Event with 5 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=5")) + exDates.add(ExDate(DateList("20220121T010203Z", Value.DATE_TIME))) } + val localEvent = AndroidEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertEquals(4, calendar.numDirectInstances(localEvent.id!!)) + } + + @Test + fun testNumDirectInstances_RecurringWithExceptions() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 5 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=5")) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220122T010203Z") + dtStart = DtStart("20220122T130203Z") + summary = "Exception on 3rd day" + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220124T010203Z") + dtStart = DtStart("20220122T160203Z") + summary = "Exception on 5th day" + }) + } + val localEvent = AndroidEvent(calendar, event, "filename.ics", null, null, 0) + localEvent.add() + + assertEquals(5 - 2, calendar.numDirectInstances(localEvent.id!!)) + } + + + @Test + fun testNumInstances_SingleInstance() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 1 instance" + } + val localEvent = AndroidEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertEquals(1, calendar.numInstances(localEvent.id!!)) + } + + @Test + fun testNumInstances_Recurring() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 5 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=5")) + } + val localEvent = AndroidEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertEquals(5, calendar.numInstances(localEvent.id!!)) + } + + @Test + fun testNumInstances_Recurring_Endless() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with infinite instances" + rRules.add(RRule("FREQ=YEARLY")) + } + val localEvent = AndroidEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertNull(calendar.numInstances(localEvent.id!!)) + } + + @Test + fun testNumInstances_Recurring_LateEnd() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event over 22 years" + rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year 2074 not supported by Android <11 Calendar Storage + } + val localEvent = AndroidEvent(calendar, event, null, null, null, 0) + localEvent.add() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + assertEquals(52, calendar.numInstances(localEvent.id!!)) + else + assertNull(calendar.numInstances(localEvent.id!!)) + } + + @Test + fun testNumInstances_Recurring_ManyInstances() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event over two years" + rRules.add(RRule("FREQ=DAILY;UNTIL=20240120T010203Z")) + } + val localEvent = AndroidEvent(calendar, event, null, null, null, 0) + localEvent.add() + + assertEquals( + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) + 365 * 2 // Android <9: does not include UNTIL (incorrect!) + else + 365 * 2 + 1, // Android ≥9: includes UNTIL (correct) + calendar.numInstances(localEvent.id!!) + ) + } + + @Test + fun testNumInstances_RecurringWithExceptions() { + val event = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 6 instances" + rRules.add(RRule("FREQ=DAILY;COUNT=6")) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220122T010203Z") + dtStart = DtStart("20220122T130203Z") + summary = "Exception on 3rd day" + }) + exceptions.add(Event().apply { + recurrenceId = RecurrenceId("20220124T010203Z") + dtStart = DtStart("20220122T160203Z") + summary = "Exception on 5th day" + }) + } + val localEvent = AndroidEvent(calendar, event, "filename.ics", null, null, 0) + localEvent.add() + + calendar.getEvent(localEvent.id!!)!! + + assertEquals(6, calendar.numInstances(localEvent.id!!)) } } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt index baca56d3..836a2c5c 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt @@ -6,23 +6,18 @@ package at.bitfire.ical4android -import android.accounts.Account -import android.content.ContentProviderClient import android.content.ContentResolver import android.content.ContentUris import android.content.ContentValues import android.content.EntityIterator import android.net.Uri import android.os.RemoteException -import android.provider.CalendarContract import android.provider.CalendarContract.Attendees import android.provider.CalendarContract.Events import android.provider.CalendarContract.EventsEntity import android.provider.CalendarContract.ExtendedProperties import android.provider.CalendarContract.Reminders -import androidx.core.content.contentValuesOf import at.bitfire.ical4android.AndroidEvent.Companion.CATEGORIES_SEPARATOR -import at.bitfire.ical4android.AndroidEvent.Companion.numInstances import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter import at.bitfire.synctools.mapping.calendar.AndroidEventBuilder import at.bitfire.synctools.mapping.calendar.AndroidEventProcessor @@ -31,7 +26,6 @@ import at.bitfire.synctools.storage.LocalStorageException import at.bitfire.synctools.storage.calendar.AndroidCalendar import at.bitfire.synctools.storage.calendar.CalendarBatchOperation import java.io.FileNotFoundException -import java.util.logging.Logger /** * Stores and retrieves VEVENT iCalendar objects (represented as [Event]s) to/from the @@ -46,9 +40,6 @@ class AndroidEvent( val calendar: AndroidCalendar ) { - private val logger: Logger - get() = Logger.getLogger(javaClass.name) - var id: Long? = null private set @@ -95,7 +86,8 @@ class AndroidEvent( /** * Returns the full event data, either from [event] or, if [event] is null, by reading event - * number [id] from the Android calendar storage + * number [id] from the Android calendar storage. + * * @throws IllegalArgumentException if event has not been saved yet * @throws FileNotFoundException if there's no event with [id] in the calendar storage * @throws RemoteException on calendar provider errors @@ -308,107 +300,6 @@ class AndroidEvent( */ const val EXTNAME_URL = ContentResolver.CURSOR_ITEM_BASE_TYPE + "/vnd.ical4android.url" - - // helpers - - /** - * Marks the event as deleted - * @param eventID - */ - fun markAsDeleted(provider: ContentProviderClient, account: Account, eventID: Long) { - provider.update( - ContentUris.withAppendedId( - Events.CONTENT_URI, - eventID - ).asSyncAdapter(account), - contentValuesOf(Events.DELETED to 1), - null, null - ) - } - - /** - * Finds the amount of direct instances this event has (without exceptions); used by [numInstances] - * to find the number of instances of exceptions. - * - * The number of returned instances may vary with the Android version. - * - * @return number of direct event instances (not counting instances of exceptions); *null* if - * the number can't be determined or if the event has no last date (recurring event without last instance) - */ - fun numDirectInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? { - // query event to get first and last instance - var first: Long? = null - var last: Long? = null - provider.query( - ContentUris.withAppendedId( - Events.CONTENT_URI, - eventID - ), - arrayOf(Events.DTSTART, Events.LAST_DATE), null, null, null - )?.use { cursor -> - cursor.moveToNext() - if (!cursor.isNull(0)) - first = cursor.getLong(0) - if (!cursor.isNull(1)) - last = cursor.getLong(1) - } - // if this event doesn't have a last occurence, it's endless and always has instances - if (first == null || last == null) - return null - - /* We can't use Long.MIN_VALUE and Long.MAX_VALUE because Android generates the instances - on the fly and it doesn't accept those values. So we use the first/last actual occurence - of the event (calculated by Android). */ - val instancesUri = CalendarContract.Instances.CONTENT_URI.asSyncAdapter(account) - .buildUpon() - .appendPath(first.toString()) // begin timestamp - .appendPath(last.toString()) // end timestamp - .build() - - var numInstances = 0 - provider.query( - instancesUri, null, - "${CalendarContract.Instances.EVENT_ID}=?", arrayOf(eventID.toString()), - null - )?.use { cursor -> - numInstances += cursor.count - } - return numInstances - } - - /** - * Finds the total number of instances this event has (including instances of exceptions) - * - * The number of returned instances may vary with the Android version. - * - * @return number of direct event instances (not counting instances of exceptions); *null* if - * the number can't be determined or if the event has no last date (recurring event without last instance) - */ - fun numInstances(provider: ContentProviderClient, account: Account, eventID: Long): Int? { - // num instances of the main event - var numInstances = numDirectInstances(provider, account, eventID) ?: return null - - // add the number of instances of every main event's exception - provider.query( - Events.CONTENT_URI, - arrayOf(Events._ID), - "${Events.ORIGINAL_ID}=?", // get exception events of the main event - arrayOf("$eventID"), null - )?.use { exceptionsEventCursor -> - while (exceptionsEventCursor.moveToNext()) { - val exceptionEventID = exceptionsEventCursor.getLong(0) - val exceptionInstances = numDirectInstances(provider, account, exceptionEventID) - - if (exceptionInstances == null) - // number of instances of exception can't be determined; so the total number of instances is also unclear - return null - - numInstances += exceptionInstances - } - } - return numInstances - } - } } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendar.kt b/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendar.kt index 4187d085..9c9f7b6c 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendar.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/storage/calendar/AndroidCalendar.kt @@ -69,6 +69,7 @@ class AndroidCalendar( * @param whereArgs arguments for selection * * @return events from this calendar which match the selection + * @throws LocalStorageException when the content provider returns an error */ fun findEvents(where: String?, whereArgs: Array?): List { val events = LinkedList() @@ -99,7 +100,9 @@ class AndroidCalendar( * Gets the main event row of a specific event, identified by its ID, from this calendar. * * @param id event ID + * * @return event row (or `null` if not found) + * @throws LocalStorageException when the content provider returns an error */ fun getEventValues(id: Long, projection: Array? = null, where: String? = null, whereArgs: Array? = null): ContentValues? { try { @@ -123,6 +126,7 @@ class AndroidCalendar( * @param whereArgs arguments for selection * * @return event IDs from this calendar which match the selection + * @throws LocalStorageException when the content provider returns an error */ fun iterateEvents(projection: Array, where: String?, whereArgs: Array?, body: (ContentValues) -> Unit) { try { @@ -137,6 +141,22 @@ class AndroidCalendar( } } + /** + * Updates a specific event's main row with the given values. + * + * @param id event ID + * @param values new values + * + * @throws LocalStorageException when the content provider returns an error + */ + fun updateEvent(id: Long, values: ContentValues) { + try { + client.update(eventUri(id), values, null, null) + } catch (e: RemoteException) { + throw LocalStorageException("Couldn't update event $id", e) + } + } + /** * Updates events in this calendar. * @@ -145,6 +165,7 @@ class AndroidCalendar( * @param whereArgs arguments for selection * * @return number of updated rows + * * @throws LocalStorageException when the content provider returns an error */ fun updateEvents(values: ContentValues, where: String?, whereArgs: Array?): Int = @@ -154,6 +175,97 @@ class AndroidCalendar( throw LocalStorageException("Couldn't update events", e) } + // event instances (these methods operate directly with event IDs and without the events themselves and thus belong to the calendar class) + + /** + * Finds the amount of direct instances this event has (without exceptions); used by [numInstances] + * to find the number of instances of exceptions. + * + * The number of returned instances may vary with the Android version. + * + * @return number of direct event instances (not counting instances of exceptions); *null* if + * the number can't be determined or if the event has no last date (recurring event without last instance) + * + * @throws LocalStorageException when the content provider returns an error + */ + fun numDirectInstances(eventId: Long): Int? { + // query event to get first and last instance + var first: Long? = null + var last: Long? = null + client.query( + eventUri(eventId), + arrayOf(Events.DTSTART, Events.LAST_DATE), null, null, null + )?.use { cursor -> + cursor.moveToNext() + if (!cursor.isNull(0)) + first = cursor.getLong(0) + if (!cursor.isNull(1)) + last = cursor.getLong(1) + } + // if this event doesn't have a last occurrence, it's endless and always has instances + if (first == null || last == null) + return null + + /* We can't use Long.MIN_VALUE and Long.MAX_VALUE because Android generates the instances + on the fly and it doesn't accept those values. So we use the first/last actual occurence + of the event (calculated by Android). */ + val instancesUri = CalendarContract.Instances.CONTENT_URI.asSyncAdapter(account) + .buildUpon() + .appendPath(first.toString()) // begin timestamp + .appendPath(last.toString()) // end timestamp + .build() + + var numInstances = 0 + try { + client.query( + instancesUri, null, + "${CalendarContract.Instances.EVENT_ID}=?", arrayOf(eventId.toString()), + null + )?.use { cursor -> + numInstances += cursor.count + } + } catch (e: RemoteException) { + throw LocalStorageException("Couldn't query number of instances for event $eventId", e) + } + return numInstances + } + + /** + * Finds the total number of instances this event has (including instances of exceptions) + * + * The number of returned instances may vary with the Android version. + * + * @return number of event instances (including instances of exceptions); *null* if + * the number can't be determined or if the event has no last date (recurring event without last instance) + */ + fun numInstances(eventId: Long): Int? { + // num instances of the main event + var numInstances = numDirectInstances(eventId) ?: return null + + // add the number of instances of every main event's exception + try { + client.query( + Events.CONTENT_URI, + arrayOf(Events._ID), + "${Events.ORIGINAL_ID}=?", // get exception events of the main event + arrayOf(eventId.toString()), null + )?.use { exceptionsEventCursor -> + while (exceptionsEventCursor.moveToNext()) { + val exceptionEventId = exceptionsEventCursor.getLong(0) + val exceptionInstances = numDirectInstances(exceptionEventId) + + if (exceptionInstances == null) + return null // number of instances of exception can't be determined; so the total number of instances is also unclear + + numInstances += exceptionInstances + } + } + } catch (e: RemoteException) { + throw LocalStorageException("Couldn't query number of exception instances for event $eventId", e) + } + return numInstances + } + // shortcuts to upper level diff --git a/lib/src/main/kotlin/at/bitfire/synctools/test/InitCalendarProviderRule.kt b/lib/src/main/kotlin/at/bitfire/synctools/test/InitCalendarProviderRule.kt index ae8c5b8b..0874dcfe 100644 --- a/lib/src/main/kotlin/at/bitfire/synctools/test/InitCalendarProviderRule.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/test/InitCalendarProviderRule.kt @@ -19,7 +19,6 @@ import at.bitfire.ical4android.AndroidEvent import at.bitfire.ical4android.Event import at.bitfire.synctools.storage.calendar.AndroidCalendar import at.bitfire.synctools.storage.calendar.AndroidCalendarProvider -import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.RRule import org.junit.Assert @@ -82,7 +81,6 @@ class InitCalendarProviderRule private constructor() : ExternalResource() { } val calendar = calendarOrNull ?: throw IllegalStateException("Couldn't create calendar") - val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry() try { // single event init val normalEvent = Event().apply { @@ -91,7 +89,7 @@ class InitCalendarProviderRule private constructor() : ExternalResource() { } val normalLocalEvent = AndroidEvent(calendar, normalEvent, null, null, null, 0) normalLocalEvent.add() - AndroidEvent.numInstances(provider, account, normalLocalEvent.id!!) + calendar.numInstances(normalLocalEvent.id!!) // recurring event init val recurringEvent = Event().apply { @@ -101,7 +99,7 @@ class InitCalendarProviderRule private constructor() : ExternalResource() { } val localRecurringEvent = AndroidEvent(calendar, recurringEvent, null, null, null, 0) localRecurringEvent.add() - AndroidEvent.numInstances(provider, account, localRecurringEvent.id!!) + calendar.numInstances(localRecurringEvent.id!!) } finally { calendar.delete() }