diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts index e04c5562..80c18b31 100644 --- a/lib/build.gradle.kts +++ b/lib/build.gradle.kts @@ -1,3 +1,9 @@ +/* + * 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 + */ + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) @@ -117,6 +123,9 @@ dependencies { exclude(group = "org.freemarker") } + // synctools.test package also provide test rules + implementation(libs.androidx.test.rules) + // instrumented tests androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(libs.androidx.test.runner) diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCalendarTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCalendarTest.kt index 3e0ebbb4..e3180c01 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCalendarTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidCalendarTest.kt @@ -17,7 +17,6 @@ import android.provider.CalendarContract.Colors import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import at.bitfire.ical4android.impl.TestCalendar -import at.bitfire.ical4android.impl.TestEvent import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter import at.bitfire.ical4android.util.MiscUtils.closeCompat import net.fortuna.ical4j.model.property.DtEnd @@ -109,12 +108,12 @@ class AndroidCalendarTest { val cal = TestCalendar.findOrCreate(testAccount, provider) try { // add event with color - TestEvent(cal, Event().apply { + AndroidEvent(cal, Event().apply { dtStart = DtStart("20210314T204200Z") dtEnd = DtEnd("20210314T204230Z") color = Css3Color.limegreen summary = "Test event with color" - }).add() + }, "remove-colors").add() AndroidCalendar.removeColors(provider, testAccount) assertEquals(0, countColors(testAccount)) diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt index c206d8f8..cd393a90 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/AndroidEventTest.kt @@ -5,13 +5,13 @@ */ package at.bitfire.ical4android -import android.Manifest import android.accounts.Account import android.content.ContentProviderClient import android.content.ContentUris import android.content.ContentValues import android.database.DatabaseUtils import android.net.Uri +import android.os.Build import android.provider.CalendarContract.ACCOUNT_TYPE_LOCAL import android.provider.CalendarContract.AUTHORITY import android.provider.CalendarContract.Attendees @@ -21,13 +21,12 @@ import android.provider.CalendarContract.ExtendedProperties import android.provider.CalendarContract.Reminders import androidx.core.content.contentValuesOf import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation -import androidx.test.rule.GrantPermissionRule import at.bitfire.ical4android.impl.TestCalendar -import at.bitfire.ical4android.impl.TestEvent import at.bitfire.ical4android.util.AndroidTimeUtils import at.bitfire.ical4android.util.DateUtils import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter import at.bitfire.ical4android.util.MiscUtils.closeCompat +import at.bitfire.synctools.test.InitCalendarProviderRule import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateList import net.fortuna.ical4j.model.DateTime @@ -59,9 +58,11 @@ import org.junit.Before import org.junit.BeforeClass import org.junit.ClassRule import org.junit.Test +import org.junit.rules.TestRule import java.net.URI import java.time.Duration import java.time.Period +import java.util.UUID import java.util.logging.Logger import kotlin.collections.plusAssign @@ -71,10 +72,7 @@ class AndroidEventTest { @JvmField @ClassRule - val permissionRule = GrantPermissionRule.grant( - Manifest.permission.READ_CALENDAR, - Manifest.permission.WRITE_CALENDAR - ) + val initCalendarProviderRule: TestRule = InitCalendarProviderRule.getInstance() lateinit var provider: ContentProviderClient @@ -171,7 +169,8 @@ class AndroidEventTest { dtStart = DtStart(DateTime()) eventBuilder() } - val uri = TestEvent(calendar, event).add() + // write event with random file name/sync_id + val uri = AndroidEvent(calendar, event, syncId = UUID.randomUUID().toString()).add() provider.query(uri, null, null, null, null)!!.use { cursor -> cursor.moveToNext() val values = ContentValues(cursor.columnCount) @@ -180,7 +179,7 @@ class AndroidEventTest { } } - private fun firstExtendedProperty(values: ContentValues, mimeType: String): String? { + private fun firstExtendedProperty(values: ContentValues): String? { val id = values.getAsInteger(Events._ID) provider.query(ExtendedProperties.CONTENT_URI.asSyncAdapter(testAccount), arrayOf(ExtendedProperties.VALUE), "${ExtendedProperties.EVENT_ID}=?", arrayOf(id.toString()), null)?.use { @@ -191,7 +190,7 @@ class AndroidEventTest { } private fun firstUnknownProperty(values: ContentValues): Property? { - val rawValue = firstExtendedProperty(values, UnknownProperty.CONTENT_ITEM_TYPE) + val rawValue = firstExtendedProperty(values) return if (rawValue != null) UnknownProperty.fromJsonString(rawValue) else @@ -588,7 +587,7 @@ class AndroidEventTest { buildEvent(true) { url = URI("https://example.com") }.let { result -> - assertEquals("https://example.com", firstExtendedProperty(result, AndroidEvent.EXTNAME_URL)) + assertEquals("https://example.com", firstExtendedProperty(result)) } } @@ -868,9 +867,7 @@ class AndroidEventTest { buildEvent(true) { alarms += VAlarm(Period.ofDays(-1)) }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(1440, reminder.getAsInteger(Reminders.MINUTES)) - } + assertEquals(1440, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) } } @@ -879,9 +876,7 @@ class AndroidEventTest { buildEvent(true) { alarms += VAlarm(Duration.ofSeconds(-10)) }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(0, reminder.getAsInteger(Reminders.MINUTES)) - } + assertEquals(0, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) } } @@ -891,9 +886,7 @@ class AndroidEventTest { buildEvent(true) { alarms += VAlarm(Duration.ofMinutes(10)) }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(-10, reminder.getAsInteger(Reminders.MINUTES)) - } + assertEquals(-10, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) } } @@ -906,9 +899,7 @@ class AndroidEventTest { trigger.parameters.add(Related.END) } }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(1320, reminder.getAsInteger(Reminders.MINUTES)) - } + assertEquals(1320, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) } } @@ -921,9 +912,7 @@ class AndroidEventTest { trigger.parameters.add(Related.END) } }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(0, reminder.getAsInteger(Reminders.MINUTES)) - } + assertEquals(0, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) } } @@ -937,9 +926,7 @@ class AndroidEventTest { trigger.parameters.add(Related.END) } }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(-130, reminder.getAsInteger(Reminders.MINUTES)) - } + assertEquals(-130, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) } } @@ -949,9 +936,7 @@ class AndroidEventTest { dtStart = DtStart(DateTime("20200621T120000", tzVienna)) alarms += VAlarm(DateTime("20200621T110000", tzVienna)) }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(60, reminder.getAsInteger(Reminders.MINUTES)) - } + assertEquals(60, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) } } @@ -961,9 +946,7 @@ class AndroidEventTest { dtStart = DtStart(DateTime("20200621T120000", tzVienna)) alarms += VAlarm(DateTime("20200621T110000", tzShanghai)) }.let { result -> - firstReminder(result)!!.let { reminder -> - assertEquals(420, reminder.getAsInteger(Reminders.MINUTES)) - } + assertEquals(420, firstReminder(result)!!.getAsInteger(Reminders.MINUTES)) } } @@ -986,9 +969,7 @@ class AndroidEventTest { buildEvent(true) { attendees += Attendee("mailto:attendee1@example.com") }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals("attendee1@example.com", attendee.getAsString(Attendees.ATTENDEE_EMAIL)) - } + assertEquals("attendee1@example.com", firstAttendee(result)!!.getAsString(Attendees.ATTENDEE_EMAIL)) } } @@ -1026,9 +1007,7 @@ class AndroidEventTest { parameters.add(Cn("Sample Attendee")) } }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals("Sample Attendee", attendee.getAsString(Attendees.ATTENDEE_NAME)) - } + assertEquals("Sample Attendee", firstAttendee(result)!!.getAsString(Attendees.ATTENDEE_NAME)) } } @@ -1231,25 +1210,22 @@ class AndroidEventTest { @Test fun testBuildAttendee_Organizer() { - for (cuType in arrayOf(null, CuType.INDIVIDUAL, CuType.UNKNOWN, CuType.GROUP, CuType("x-custom-cutype"))) - buildEvent(true) { - attendees += Attendee(URI("mailto", testAccount.name, null)) - }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(testAccount.name, attendee.getAsString(Attendees.ATTENDEE_EMAIL)) - assertEquals(Attendees.TYPE_REQUIRED, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) - assertEquals(Attendees.RELATIONSHIP_ORGANIZER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) - } + buildEvent(true) { + attendees += Attendee(URI("mailto", testAccount.name, null)) + }.let { result -> + firstAttendee(result)!!.let { attendee -> + assertEquals(testAccount.name, attendee.getAsString(Attendees.ATTENDEE_EMAIL)) + assertEquals(Attendees.TYPE_REQUIRED, attendee.getAsInteger(Attendees.ATTENDEE_TYPE)) + assertEquals(Attendees.RELATIONSHIP_ORGANIZER, attendee.getAsInteger(Attendees.ATTENDEE_RELATIONSHIP)) } + } } @Test fun testBuildAttendee_PartStat_None() { buildEvent(true) { attendees += Attendee("mailto:attendee@example.com") }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.ATTENDEE_STATUS_INVITED, attendee.getAsInteger(Attendees.ATTENDEE_STATUS)) - } + assertEquals(Attendees.ATTENDEE_STATUS_INVITED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) } } @@ -1260,9 +1236,7 @@ class AndroidEventTest { parameters.add(PartStat.NEEDS_ACTION) } }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.ATTENDEE_STATUS_INVITED, attendee.getAsInteger(Attendees.ATTENDEE_STATUS)) - } + assertEquals(Attendees.ATTENDEE_STATUS_INVITED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) } } @@ -1273,9 +1247,7 @@ class AndroidEventTest { parameters.add(PartStat.ACCEPTED) } }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.ATTENDEE_STATUS_ACCEPTED, attendee.getAsInteger(Attendees.ATTENDEE_STATUS)) - } + assertEquals(Attendees.ATTENDEE_STATUS_ACCEPTED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) } } @@ -1286,9 +1258,7 @@ class AndroidEventTest { parameters.add(PartStat.DECLINED) } }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.ATTENDEE_STATUS_DECLINED, attendee.getAsInteger(Attendees.ATTENDEE_STATUS)) - } + assertEquals(Attendees.ATTENDEE_STATUS_DECLINED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) } } @@ -1299,9 +1269,7 @@ class AndroidEventTest { parameters.add(PartStat.TENTATIVE) } }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.ATTENDEE_STATUS_TENTATIVE, attendee.getAsInteger(Attendees.ATTENDEE_STATUS)) - } + assertEquals(Attendees.ATTENDEE_STATUS_TENTATIVE, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) } } @@ -1312,9 +1280,7 @@ class AndroidEventTest { parameters.add(PartStat.DELEGATED) } }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.ATTENDEE_STATUS_NONE, attendee.getAsInteger(Attendees.ATTENDEE_STATUS)) - } + assertEquals(Attendees.ATTENDEE_STATUS_NONE, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) } } @@ -1325,9 +1291,7 @@ class AndroidEventTest { parameters.add(PartStat("X-WILL-ASK")) } }.let { result -> - firstAttendee(result)!!.let { attendee -> - assertEquals(Attendees.ATTENDEE_STATUS_INVITED, attendee.getAsInteger(Attendees.ATTENDEE_STATUS)) - } + assertEquals(Attendees.ATTENDEE_STATUS_INVITED, firstAttendee(result)!!.getAsInteger(Attendees.ATTENDEE_STATUS)) } } @@ -2408,7 +2372,7 @@ class AndroidEventTest { event.dtStart = DtStart("20150502T120000Z") event.dtEnd = DtEnd("20150502T130000Z") event.organizer = Organizer(URI("mailto:organizer@example.com")) - val uri = TestEvent(calendar, event).add() + val uri = AndroidEvent(calendar, event, "update-event").add() // update test event in calendar val testEvent = calendar.findById(ContentUris.parseId(uri)) @@ -2442,7 +2406,7 @@ class AndroidEventTest { dtStart = DtStart(DateTime()) color = Css3Color.silver } - val uri = TestEvent(calendar, event).add() + val uri = AndroidEvent(calendar, event, "reset-color").add() val id = ContentUris.parseId(uri) // verify that it has color @@ -2465,7 +2429,7 @@ class AndroidEventTest { event.summary = "Sample event with STATUS" event.dtStart = DtStart("20150502T120000Z") event.dtEnd = DtEnd("20150502T130000Z") - val uri = TestEvent(calendar, event).add() + val uri = AndroidEvent(calendar, event, "update-status-from-null").add() // update test event in calendar val testEvent = calendar.findById(ContentUris.parseId(uri)) @@ -2495,7 +2459,7 @@ class AndroidEventTest { event.dtStart = DtStart("20150502T120000Z") event.dtEnd = DtEnd("20150502T130000Z") event.status = Status.VEVENT_CONFIRMED - val uri = TestEvent(calendar, event).add() + val uri = AndroidEvent(calendar, event, "update-status-to-null").add() // update test event in calendar val testEvent = calendar.findById(ContentUris.parseId(uri)) @@ -2528,7 +2492,7 @@ class AndroidEventTest { event.dtEnd = DtEnd("20150502T130000Z") for (i in 0 until 20) event.attendees += Attendee(URI("mailto:att$i@example.com")) - val uri = TestEvent(calendar, event).add() + val uri = AndroidEvent(calendar, event, "transaction").add() val testEvent = calendar.findById(ContentUris.parseId(uri)) try { @@ -2538,4 +2502,240 @@ 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(provider, testAccount, localEvent.id!!) + + // Get the status of whether the event is deleted + provider.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(provider, 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(provider, 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(provider, 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(provider, testAccount, localEvent.id!!)) + else + assertNull(AndroidEvent.numDirectInstances(provider, 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(provider, 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(provider, 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(provider, 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(provider, 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(provider, 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(provider, 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(provider, testAccount, localEvent.id!!)) + else + assertNull(AndroidEvent.numInstances(provider, 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.Q) + 365 * 2 // Android <10: does not include UNTIL (incorrect!) + else + 365 * 2 + 1, // Android ≥10: includes UNTIL (correct) + AndroidEvent.numInstances(provider, 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.findById(localEvent.id!!) + + assertEquals(6, AndroidEvent.numInstances(provider, testAccount, localEvent.id!!)) + } + } \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsStyleProvidersTaskTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsStyleProvidersTaskTest.kt index 2eff0db3..9ddf2462 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsStyleProvidersTaskTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/DmfsStyleProvidersTaskTest.kt @@ -7,7 +7,7 @@ package at.bitfire.ical4android import androidx.test.platform.app.InstrumentationRegistry -import at.bitfire.synctools.GrantPermissionOrSkipRule +import at.bitfire.synctools.test.GrantPermissionOrSkipRule import org.junit.After import org.junit.Assert.assertNotNull import org.junit.Before diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt index 6bba2a25..d6739f51 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt @@ -13,7 +13,7 @@ import androidx.test.platform.app.InstrumentationRegistry import at.bitfire.ical4android.impl.TestJtxCollection import at.bitfire.ical4android.impl.testProdId import at.bitfire.ical4android.util.MiscUtils.closeCompat -import at.bitfire.synctools.GrantPermissionOrSkipRule +import at.bitfire.synctools.test.GrantPermissionOrSkipRule import at.techbee.jtx.JtxContract import at.techbee.jtx.JtxContract.asSyncAdapter import junit.framework.TestCase.assertEquals diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt index 506565d0..ba892521 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt @@ -16,7 +16,7 @@ import androidx.test.platform.app.InstrumentationRegistry import at.bitfire.ical4android.impl.TestJtxCollection import at.bitfire.ical4android.impl.testProdId import at.bitfire.ical4android.util.MiscUtils.closeCompat -import at.bitfire.synctools.GrantPermissionOrSkipRule +import at.bitfire.synctools.test.GrantPermissionOrSkipRule import at.techbee.jtx.JtxContract import at.techbee.jtx.JtxContract.JtxICalObject import at.techbee.jtx.JtxContract.JtxICalObject.Component 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 a834b333..6cb6864d 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestCalendar.kt @@ -18,7 +18,7 @@ class TestCalendar( account: Account, providerClient: ContentProviderClient, id: Long -): AndroidCalendar(account, providerClient, TestEvent.Factory, id) { +) : AndroidCalendar(account, providerClient, id) { companion object { fun findOrCreate(account: Account, provider: ContentProviderClient): TestCalendar { diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestEvent.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestEvent.kt deleted file mode 100644 index ce8c39db..00000000 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/TestEvent.kt +++ /dev/null @@ -1,30 +0,0 @@ -/* - * 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.ical4android.impl - -import android.content.ContentValues -import at.bitfire.ical4android.AndroidCalendar -import at.bitfire.ical4android.AndroidEvent -import at.bitfire.ical4android.AndroidEventFactory -import at.bitfire.ical4android.Event -import java.util.UUID - -class TestEvent: AndroidEvent { - - constructor(calendar: AndroidCalendar, values: ContentValues) - : super(calendar, values) - - constructor(calendar: TestCalendar, event: Event) - : super(calendar, event, UUID.randomUUID().toString(), null, null, 0) - - - object Factory: AndroidEventFactory { - override fun fromProvider(calendar: AndroidCalendar, values: ContentValues) = - TestEvent(calendar, values) - } - -} diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/JtxBatchOperationTest.kt b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/JtxBatchOperationTest.kt index 0a21e0dd..39abe136 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/JtxBatchOperationTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/synctools/storage/JtxBatchOperationTest.kt @@ -16,7 +16,7 @@ import at.bitfire.ical4android.JtxICalObject import at.bitfire.ical4android.TaskProvider import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter import at.bitfire.ical4android.util.MiscUtils.closeCompat -import at.bitfire.synctools.GrantPermissionOrSkipRule +import at.bitfire.synctools.test.GrantPermissionOrSkipRule import at.techbee.jtx.JtxContract import io.mockk.mockk import org.junit.After diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendar.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendar.kt index 05f820ad..77753a08 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendar.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendar.kt @@ -31,10 +31,9 @@ import java.util.logging.Logger * Communicates with the Android Contacts Provider which uses an SQLite * database to store the events. */ -open class AndroidCalendar( +open class AndroidCalendar( val account: Account, val provider: ContentProviderClient, - val eventFactory: AndroidEventFactory, /** the calendar ID ([Calendars._ID]) **/ val id: Long @@ -100,14 +99,14 @@ open class AndroidCalendar( * @param _whereArgs arguments for selection * @return events from this calendar which match the selection */ - fun queryEvents(_where: String? = null, _whereArgs: Array? = null): List { + fun queryEvents(_where: String? = null, _whereArgs: Array? = null): List { val where = "(${_where ?: "1"}) AND " + Events.CALENDAR_ID + "=?" val whereArgs = (_whereArgs ?: arrayOf()) + id.toString() - val events = LinkedList() + val events = LinkedList() provider.query(Events.CONTENT_URI.asSyncAdapter(account), null, where, whereArgs, null)?.use { cursor -> while (cursor.moveToNext()) - events += eventFactory.fromProvider(this, cursor.toValues()) + events += AndroidEvent(this, cursor.toValues()) } return events } @@ -216,7 +215,12 @@ open class AndroidCalendar( provider.delete(Colors.CONTENT_URI.asSyncAdapter(account), null, null) } - fun> findByID(account: Account, provider: ContentProviderClient, factory: AndroidCalendarFactory, id: Long): T { + fun findByID( + account: Account, + provider: ContentProviderClient, + factory: AndroidCalendarFactory, + id: Long + ): T { val iterCalendars = CalendarEntity.newEntityIterator( provider.query(ContentUris.withAppendedId(CalendarEntity.CONTENT_URI, id).asSyncAdapter(account), null, null, null, null) ) @@ -233,7 +237,13 @@ open class AndroidCalendar( throw FileNotFoundException() } - fun> find(account: Account, provider: ContentProviderClient, factory: AndroidCalendarFactory, where: String?, whereArgs: Array?): List { + fun find( + account: Account, + provider: ContentProviderClient, + factory: AndroidCalendarFactory, + where: String?, + whereArgs: Array? + ): List { val iterCalendars = CalendarEntity.newEntityIterator( provider.query(CalendarEntity.CONTENT_URI.asSyncAdapter(account), null, where, whereArgs, null) ) @@ -253,4 +263,11 @@ open class AndroidCalendar( } + + // default factory (will be removed as soon as AndroidCalendar is not open anymore) + object Factory : AndroidCalendarFactory { + override fun newInstance(account: Account, provider: ContentProviderClient, id: Long) = + AndroidCalendar(account, provider, id) + } + } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendarFactory.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendarFactory.kt index 4ae5e51c..a043421e 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendarFactory.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidCalendarFactory.kt @@ -9,7 +9,7 @@ package at.bitfire.ical4android import android.accounts.Account import android.content.ContentProviderClient -interface AndroidCalendarFactory> { +interface AndroidCalendarFactory { fun newInstance(account: Account, provider: ContentProviderClient, id: Long): T diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt index 85b86ea7..e8257cd5 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEvent.kt @@ -6,12 +6,15 @@ 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.Colors import android.provider.CalendarContract.Events @@ -19,8 +22,9 @@ import android.provider.CalendarContract.EventsEntity import android.provider.CalendarContract.ExtendedProperties import android.provider.CalendarContract.Reminders import android.util.Patterns -import androidx.annotation.CallSuper +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.AndroidTimeUtils import at.bitfire.ical4android.util.DateUtils import at.bitfire.ical4android.util.MiscUtils.asSyncAdapter @@ -84,16 +88,17 @@ import java.util.logging.Logger * Important: To use recurrence exceptions, you MUST set _SYNC_ID and ORIGINAL_SYNC_ID * in populateEvent() / buildEvent. Setting _ID and ORIGINAL_ID is not sufficient. */ -open class AndroidEvent( - val calendar: AndroidCalendar +class AndroidEvent( + val calendar: AndroidCalendar ) { - protected val logger: Logger by lazy { Logger.getLogger(AndroidEvent::class.java.name) } + private val logger: Logger + get() = Logger.getLogger(javaClass.name) var id: Long? = null - protected set + private set - open var syncId: String? = null + var syncId: String? = null var eTag: String? = null var scheduleTag: String? = null @@ -104,7 +109,7 @@ open class AndroidEvent( * * @param values database row with all columns, as returned by the calendar provider */ - constructor(calendar: AndroidCalendar<*>, values: ContentValues) : this(calendar) { + constructor(calendar: AndroidCalendar, values: ContentValues) : this(calendar) { this.id = values.getAsLong(Events._ID) this.syncId = values.getAsString(Events._SYNC_ID) this.eTag = values.getAsString(COLUMN_ETAG) @@ -114,9 +119,17 @@ open class AndroidEvent( /** * Creates a new object from an event which doesn't exist in the calendar storage yet. + * * @param event event that can be saved into the calendar storage */ - constructor(calendar: AndroidCalendar<*>, event: Event, syncId: String?, eTag: String?, scheduleTag: String?, flags: Int) : this(calendar) { + constructor( + calendar: AndroidCalendar, + event: Event, + syncId: String?, + eTag: String? = null, + scheduleTag: String? = null, + flags: Int = 0 + ) : this(calendar) { this.event = event this.syncId = syncId this.eTag = eTag @@ -167,7 +180,7 @@ open class AndroidEvent( for (subValue in e.subValues) { val subValues = subValue.values.removeBlankStrings() when (subValue.uri) { - Attendees.CONTENT_URI -> populateAttendee(subValues, isOrganizer) + Attendees.CONTENT_URI -> populateAttendee(subValues) Reminders.CONTENT_URI -> populateReminder(subValues) ExtendedProperties.CONTENT_URI -> populateExtended(subValues) } @@ -193,7 +206,7 @@ open class AndroidEvent( * * @param row values of an [Events] row, as returned by the calendar provider */ - protected fun populateEvent(row: ContentValues, groupScheduled: Boolean) { + private fun populateEvent(row: ContentValues, groupScheduled: Boolean) { logger.log(Level.FINE, "Read event entity from calender provider", row) val event = requireNotNull(event) @@ -385,7 +398,7 @@ open class AndroidEvent( } } - protected open fun populateAttendee(row: ContentValues, isOrganizer: Boolean) { + private fun populateAttendee(row: ContentValues) { logger.log(Level.FINE, "Read event attendee from calender provider", row) try { @@ -426,7 +439,7 @@ open class AndroidEvent( } } - protected open fun populateReminder(row: ContentValues) { + private fun populateReminder(row: ContentValues) { logger.log(Level.FINE, "Read event reminder from calender provider", row) val event = requireNotNull(event) @@ -460,7 +473,7 @@ open class AndroidEvent( event.alarms += alarm } - protected open fun populateExtended(row: ContentValues) { + private fun populateExtended(row: ContentValues) { val name = row.getAsString(ExtendedProperties.NAME) val rawValue = row.getAsString(ExtendedProperties.VALUE) logger.log(Level.FINE, "Read extended property from calender provider", arrayOf(name, rawValue)) @@ -491,7 +504,7 @@ open class AndroidEvent( } } - protected open fun populateExceptions() { + private fun populateExceptions() { requireNotNull(id) val event = requireNotNull(event) @@ -501,7 +514,7 @@ open class AndroidEvent( while (c.moveToNext()) { val values = c.toValues(true) try { - val exception = calendar.eventFactory.fromProvider(calendar, values) + val exception = AndroidEvent(calendar, values) val exceptionEvent = exception.event!! val recurrenceId = exceptionEvent.recurrenceId!! @@ -753,7 +766,7 @@ open class AndroidEvent( return batch.commit() } - protected fun deleteExceptions(batch: CalendarBatchOperation) { + private fun deleteExceptions(batch: CalendarBatchOperation) { val existingId = requireNotNull(id) batch += CpoBuilder .newDelete(Events.CONTENT_URI.asSyncAdapter(calendar.account)) @@ -770,7 +783,7 @@ open class AndroidEvent( * @param recurrence event to be used as data source; *null*: use this AndroidEvent's main [event] as source * @param builder data row builder to be used */ - protected fun buildEvent(recurrence: Event?, builder: CpoBuilder) { + private fun buildEvent(recurrence: Event?, builder: CpoBuilder) { val event = recurrence ?: requireNotNull(event) val dtStart = event.dtStart ?: throw InvalidCalendarException("Events must have DTSTART") @@ -1003,7 +1016,7 @@ open class AndroidEvent( }) } - protected open fun insertReminder(batch: CalendarBatchOperation, idxEvent: Int?, alarm: VAlarm) { + private fun insertReminder(batch: CalendarBatchOperation, idxEvent: Int?, alarm: VAlarm) { val builder = CpoBuilder .newInsert(Reminders.CONTENT_URI.asSyncAdapter(calendar.account)) .withEventId(Reminders.EVENT_ID, idxEvent) @@ -1025,7 +1038,7 @@ open class AndroidEvent( batch += builder } - protected open fun insertAttendee(batch: CalendarBatchOperation, idxEvent: Int?, attendee: Attendee, organizer: String) { + private fun insertAttendee(batch: CalendarBatchOperation, idxEvent: Int?, attendee: Attendee, organizer: String) { val builder = CpoBuilder .newInsert(Attendees.CONTENT_URI.asSyncAdapter(calendar.account)) .withEventId(Attendees.EVENT_ID, idxEvent) @@ -1062,7 +1075,7 @@ open class AndroidEvent( batch += builder } - protected open fun insertExtendedProperty(batch: CalendarBatchOperation, idxEvent: Int?, name: String, value: String) { + private fun insertExtendedProperty(batch: CalendarBatchOperation, idxEvent: Int?, name: String, value: String) { val builder = CpoBuilder .newInsert(ExtendedProperties.CONTENT_URI.asSyncAdapter(calendar.account)) .withEventId(ExtendedProperties.EVENT_ID, idxEvent) @@ -1071,7 +1084,7 @@ open class AndroidEvent( batch += builder } - protected open fun insertCategories(batch: CalendarBatchOperation, idxEvent: Int?) { + private fun insertCategories(batch: CalendarBatchOperation, idxEvent: Int?) { val rawCategories = event!!.categories // concatenate, separate by backslash .joinToString(CATEGORIES_SEPARATOR.toString()) { category -> // drop occurrences of CATEGORIES_SEPARATOR in category names @@ -1080,7 +1093,7 @@ open class AndroidEvent( insertExtendedProperty(batch, idxEvent, EXTNAME_CATEGORIES, rawCategories) } - protected open fun insertUnknownProperty(batch: CalendarBatchOperation, idxEvent: Int?, property: Property) { + private fun insertUnknownProperty(batch: CalendarBatchOperation, idxEvent: Int?, property: Property) { if (property.value == null) { logger.warning("Ignoring unknown property with null value") return @@ -1112,7 +1125,7 @@ open class AndroidEvent( } - protected fun CpoBuilder.withEventId(column: String, idxEvent: Int?): CpoBuilder { + private fun CpoBuilder.withEventId(column: String, idxEvent: Int?): CpoBuilder { if (idxEvent != null) withValueBackReference(column, idxEvent) else @@ -1121,12 +1134,11 @@ open class AndroidEvent( } - protected fun eventSyncURI(): Uri { + private fun eventSyncURI(): Uri { val id = requireNotNull(id) return ContentUris.withAppendedId(Events.CONTENT_URI, id).asSyncAdapter(calendar.account) } - @CallSuper override fun toString(): String = "AndroidEvent(calendar=$calendar, id=$id, event=$_event)" @@ -1180,6 +1192,107 @@ open 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/ical4android/AndroidEventFactory.kt b/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEventFactory.kt deleted file mode 100644 index eef6bc2a..00000000 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEventFactory.kt +++ /dev/null @@ -1,15 +0,0 @@ -/* - * 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.ical4android - -import android.content.ContentValues - -interface AndroidEventFactory { - - fun fromProvider(calendar: AndroidCalendar, values: ContentValues): T - -} \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/synctools/GrantPermissionOrSkipRule.kt b/lib/src/main/kotlin/at/bitfire/synctools/test/GrantPermissionOrSkipRule.kt similarity index 97% rename from lib/src/androidTest/kotlin/at/bitfire/synctools/GrantPermissionOrSkipRule.kt rename to lib/src/main/kotlin/at/bitfire/synctools/test/GrantPermissionOrSkipRule.kt index ce834ea0..584acd7a 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/synctools/GrantPermissionOrSkipRule.kt +++ b/lib/src/main/kotlin/at/bitfire/synctools/test/GrantPermissionOrSkipRule.kt @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.synctools +package at.bitfire.synctools.test import androidx.test.rule.GrantPermissionRule import org.junit.Assume diff --git a/lib/src/main/kotlin/at/bitfire/synctools/test/InitCalendarProviderRule.kt b/lib/src/main/kotlin/at/bitfire/synctools/test/InitCalendarProviderRule.kt new file mode 100644 index 00000000..206a078c --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/synctools/test/InitCalendarProviderRule.kt @@ -0,0 +1,122 @@ +/* + * 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.test + +import android.Manifest +import android.accounts.Account +import android.content.ContentProviderClient +import android.content.ContentUris +import android.content.ContentValues +import android.os.Build +import android.provider.CalendarContract +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import at.bitfire.ical4android.AndroidCalendar +import at.bitfire.ical4android.AndroidEvent +import at.bitfire.ical4android.Event +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.RRule +import org.junit.Assert +import org.junit.rules.ExternalResource +import org.junit.rules.RuleChain +import java.util.logging.Logger + +/** + * JUnit ClassRule which initializes the AOSP CalendarProvider. + * + * It seems that the calendar provider unfortunately forgets the very first requests when it is used the very first time, + * maybe by some wrongly synchronized database initialization. So things like querying the instances + * fails in this case. + * + * So this rule is needed to allow tests which need the calendar provider to succeed even when the calendar provider + * is used the very first time (especially in CI tests / a fresh emulator). + */ +class InitCalendarProviderRule private constructor() : ExternalResource() { + + companion object { + + private var isInitialized = false + private val logger = Logger.getLogger(InitCalendarProviderRule::javaClass.name) + + fun getInstance(): RuleChain = RuleChain + .outerRule(GrantPermissionRule.grant(Manifest.permission.READ_CALENDAR, Manifest.permission.WRITE_CALENDAR)) + .around(InitCalendarProviderRule()) + + } + + override fun before() { + if (!isInitialized) { + logger.info("Initializing calendar provider") + if (Build.VERSION.SDK_INT < 31) + logger.warning("Calendar provider initialization may or may not work. See InitCalendarProviderRule") + + val context = InstrumentationRegistry.getInstrumentation().targetContext + val client = context.contentResolver.acquireContentProviderClient(CalendarContract.AUTHORITY) + Assert.assertNotNull("Couldn't acquire calendar provider", client) + + client!!.use { + initCalendarProvider(client) + isInitialized = true + } + } + } + + private fun initCalendarProvider(provider: ContentProviderClient) { + val account = Account("LocalCalendarTest", CalendarContract.ACCOUNT_TYPE_LOCAL) + + // Sometimes, the calendar provider returns an ID for the created calendar, but then fails to find it. + var calendarOrNull: AndroidCalendar? = null + for (i in 0..50) { + calendarOrNull = createAndVerifyCalendar(account, provider) + if (calendarOrNull != null) + break + else + Thread.sleep(100) + } + val calendar = calendarOrNull ?: throw IllegalStateException("Couldn't create calendar") + + try { + // single event init + val normalEvent = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event with 1 instance" + } + val normalLocalEvent = AndroidEvent(calendar, normalEvent, null, null, null, 0) + normalLocalEvent.add() + AndroidEvent.Companion.numInstances(provider, account, normalLocalEvent.id!!) + + // recurring event init + val recurringEvent = Event().apply { + dtStart = DtStart("20220120T010203Z") + summary = "Event over 22 years" + rRules.add(RRule("FREQ=YEARLY;UNTIL=20740119T010203Z")) // year needs to be >2074 (not supported by Android <11 Calendar Storage) + } + val localRecurringEvent = AndroidEvent(calendar, recurringEvent, null, null, null, 0) + localRecurringEvent.add() + AndroidEvent.Companion.numInstances(provider, account, localRecurringEvent.id!!) + } finally { + calendar.delete() + } + } + + private fun createAndVerifyCalendar(account: Account, provider: ContentProviderClient): AndroidCalendar? { + val uri = AndroidCalendar.Companion.create(account, provider, ContentValues()) + + return try { + AndroidCalendar.Companion.findByID( + account, + provider, + AndroidCalendar.Factory, + ContentUris.parseId(uri) + ) + } catch (e: Exception) { + logger.warning("Couldn't find calendar after creation: $e") + null + } + } + +} \ No newline at end of file