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/EventTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/EventTest.kt index f2f4d725..0104df36 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/EventTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/EventTest.kt @@ -6,6 +6,7 @@ package at.bitfire.ical4android +import at.bitfire.ical4android.impl.testProdId import at.bitfire.ical4android.util.DateUtils import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateTime @@ -76,7 +77,7 @@ class EventTest { e.alarms += VAlarm(Duration.ofMinutes(-30)) e.attendees += Attendee("mailto:test@example.com") val baos = ByteArrayOutputStream() - e.write(baos) + e.write(baos, testProdId) val ical = baos.toString() assertTrue("BEGIN:VTIMEZONE.+BEGIN:STANDARD.+END:STANDARD.+END:VTIMEZONE".toRegex(RegexOption.DOT_MATCHES_ALL).containsMatchIn(ical)) @@ -141,7 +142,7 @@ class EventTest { ) } val baos = ByteArrayOutputStream() - event.write(baos) + event.write(baos, testProdId) val iCal = baos.toString() assertTrue(iCal.contains("UID:test1\r\n")) assertTrue(iCal.contains("DTSTART;TZID=Europe/Berlin:20190117T083000\r\n")) @@ -250,10 +251,10 @@ class EventTest { e.alarms += VAlarm(Duration.ofHours(-1)) val os = ByteArrayOutputStream() - e.write(os) + e.write(os, testProdId) val raw = os.toString(Charsets.UTF_8.name()) - assertTrue(raw.contains("PRODID:${ICalendar.prodId.value}")) + assertTrue(raw.contains("PRODID:${testProdId.value}")) assertTrue(raw.contains("UID:SAMPLEUID")) assertTrue(raw.contains("DTSTART;TZID=Europe/Berlin:20190101T100000")) assertTrue(raw.contains("DTSTAMP:")) diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt index b863d3db..d6739f51 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxCollectionTest.kt @@ -11,8 +11,9 @@ import android.content.ContentProviderClient import android.content.ContentValues 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 @@ -148,9 +149,10 @@ class JtxCollectionTest { client.insert(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(testAccount), cv1) client.insert(JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(testAccount), cv2) - val ics = collections[0].getICSForCollection() + val ics = collections[0].getICSForCollection(testProdId) assertTrue(ics.contains(Regex("BEGIN:VCALENDAR(\\n*|\\r*|\\t*|.*)*END:VCALENDAR"))) - assertTrue(ics.contains("PRODID:+//IDN bitfire.at//ical4android")) + System.err.println(ics) + assertTrue(ics.contains("PRODID:${testProdId.value}")) assertTrue(ics.contains("SUMMARY:summary")) assertTrue(ics.contains("SUMMARY:entry2")) assertTrue(ics.contains(Regex("BEGIN:VJOURNAL(\\n*|\\r*|\\t*|.*)*END:VJOURNAL"))) diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt index 73a1c980..ba892521 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt @@ -14,8 +14,9 @@ import android.os.ParcelFileDescriptor import androidx.core.content.pm.PackageInfoCompat 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 @@ -881,7 +882,7 @@ class JtxICalObjectTest { val os = ByteArrayOutputStream() - iCalObject[0].write(os) + iCalObject[0].write(os, testProdId) val iCalOut = ICalendar.fromReader(os.toByteArray().inputStream().reader()) diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/TaskTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/TaskTest.kt index d2d58da8..fcc8a6f2 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/TaskTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/TaskTest.kt @@ -6,13 +6,13 @@ package at.bitfire.ical4android +import at.bitfire.ical4android.impl.testProdId import at.bitfire.ical4android.util.DateUtils import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateList import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.Parameter import net.fortuna.ical4j.model.TimeZone -import net.fortuna.ical4j.model.TimeZoneRegistryFactory import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.parameter.RelType import net.fortuna.ical4j.model.parameter.Value @@ -207,10 +207,10 @@ class TaskTest { t.alarms += alarm val os = ByteArrayOutputStream() - t.write(os) + t.write(os, testProdId) val raw = os.toString(Charsets.UTF_8.name()) - assertTrue(raw.contains("PRODID:${t.prodId().value}")) + assertTrue(raw.contains("PRODID:${testProdId.value}")) assertTrue(raw.contains("UID:SAMPLEUID")) assertTrue(raw.contains("DTSTAMP:")) assertTrue(raw.contains("DTSTART;TZID=Europe/Berlin:20190101T100000")) @@ -267,7 +267,7 @@ class TaskTest { private fun regenerate(t: Task): Task { val os = ByteArrayOutputStream() - t.write(os) + t.write(os, testProdId) return Task.tasksFromReader(InputStreamReader(ByteArrayInputStream(os.toByteArray()), Charsets.UTF_8)).first() } diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEventFactory.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/Constants.kt similarity index 52% rename from lib/src/main/kotlin/at/bitfire/ical4android/AndroidEventFactory.kt rename to lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/Constants.kt index eef6bc2a..6666d401 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/AndroidEventFactory.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/impl/Constants.kt @@ -4,12 +4,8 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -package at.bitfire.ical4android +package at.bitfire.ical4android.impl -import android.content.ContentValues +import net.fortuna.ical4j.model.property.ProdId -interface AndroidEventFactory { - - fun fromProvider(calendar: AndroidCalendar, values: ContentValues): T - -} \ No newline at end of file +val testProdId = ProdId("bitfireAT/synctools test") 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/androidTest/kotlin/at/bitfire/vcard4android/AndroidContactTest.kt b/lib/src/androidTest/kotlin/at/bitfire/vcard4android/AndroidContactTest.kt index b3474753..2e82630b 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/vcard4android/AndroidContactTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/vcard4android/AndroidContactTest.kt @@ -17,14 +17,20 @@ import androidx.test.platform.app.InstrumentationRegistry import androidx.test.rule.GrantPermissionRule import at.bitfire.synctools.storage.LocalStorageException import at.bitfire.vcard4android.impl.TestAddressBook +import at.bitfire.vcard4android.impl.testProductId import at.bitfire.vcard4android.property.XAbDate import ezvcard.VCardVersion import ezvcard.property.Address import ezvcard.property.Birthday import ezvcard.property.Email import ezvcard.util.PartialDate -import org.junit.* -import org.junit.Assert.* +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.BeforeClass +import org.junit.ClassRule +import org.junit.Test import java.io.ByteArrayOutputStream import java.io.StringReader import java.time.LocalDate @@ -206,8 +212,8 @@ class AndroidContactTest { * So, ADR value components may contain DQUOTE (0x22) and don't have to be encoded as defined in RFC 6868 */ val os = ByteArrayOutputStream() - contact.writeVCard(VCardVersion.V4_0, os) + contact.writeVCard(VCardVersion.V4_0, os, testProductId) assertTrue(os.toString().contains("ADR;LABEL=My ^'Label^'\\nLine 2:;;Street \"Address\";;;;")) } -} +} \ No newline at end of file diff --git a/lib/src/androidTest/kotlin/at/bitfire/vcard4android/impl/Constants.kt b/lib/src/androidTest/kotlin/at/bitfire/vcard4android/impl/Constants.kt new file mode 100644 index 00000000..0b881e44 --- /dev/null +++ b/lib/src/androidTest/kotlin/at/bitfire/vcard4android/impl/Constants.kt @@ -0,0 +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 + */ + +package at.bitfire.vcard4android.impl + +val testProductId = "bitfireAT/synctools test" \ No newline at end of file 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..af4cbcec 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 @@ -56,6 +60,7 @@ 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.ExRule +import net.fortuna.ical4j.model.property.LastModified import net.fortuna.ical4j.model.property.Organizer import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule @@ -84,44 +89,56 @@ 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 var flags: Int = 0 + var lastModified: Long = 0L /** * Creates a new object from an event which already exists in the calendar storage. * * @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) this.scheduleTag = values.getAsString(COLUMN_SCHEDULE_TAG) this.flags = values.getAsInteger(COLUMN_FLAGS) ?: 0 + this.lastModified = values.getAsLong(COLUMN_LAST_MODIFIED) ?: 0L } /** * 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 this.scheduleTag = scheduleTag this.flags = flags + this.lastModified = event.lastModified?.dateTime?.time ?: 0L } private var _event: Event? = null @@ -167,7 +184,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 +210,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) @@ -318,6 +335,9 @@ open class AndroidEvent( event.uid = row.getAsString(Events.UID_2445) event.sequence = row.getAsInteger(COLUMN_SEQUENCE) event.isOrganizer = row.getAsBoolean(Events.IS_ORGANIZER) + event.lastModified = row.getAsLong(COLUMN_LAST_MODIFIED)?.let { time -> + LastModified(DateTime(time)) + } event.summary = row.getAsString(Events.TITLE) event.location = row.getAsString(Events.EVENT_LOCATION) @@ -385,7 +405,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 +446,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 +480,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 +511,7 @@ open class AndroidEvent( } } - protected open fun populateExceptions() { + private fun populateExceptions() { requireNotNull(id) val event = requireNotNull(event) @@ -501,7 +521,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 +773,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 +790,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") @@ -800,6 +820,7 @@ open class AndroidEvent( builder.withValue(Events._SYNC_ID, syncId) .withValue(COLUMN_ETAG, eTag) .withValue(COLUMN_SCHEDULE_TAG, scheduleTag) + .withValue(COLUMN_LAST_MODIFIED, lastModified) else builder.withValue(Events.ORIGINAL_SYNC_ID, syncId) @@ -1003,7 +1024,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 +1046,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 +1083,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 +1092,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 +1101,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 +1133,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 +1142,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)" @@ -1154,6 +1174,11 @@ open class AndroidEvent( */ const val COLUMN_SCHEDULE_TAG = Events.SYNC_DATA4 + /** + * Custom sync column to store the last modified value of an event. + */ + const val COLUMN_LAST_MODIFIED = Events.SYNC_DATA5 + /** * VEVENT CATEGORIES are stored as an extended property with this [ExtendedProperties.NAME]. * @@ -1180,6 +1205,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/Event.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt index ec12145b..f8a3c372 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Event.kt @@ -97,10 +97,16 @@ data class Event( val unknownProperties: LinkedList = LinkedList() ) : ICalendar() { - fun write(os: OutputStream) { + /** + * Generates an iCalendar from the Event. + * + * @param os stream that the iCalendar is written to + * @param prodId `PRODID` that identifies the app + */ + fun write(os: OutputStream, prodId: ProdId) { val ical = Calendar() ical.properties += Version.VERSION_2_0 - ical.properties += prodId() + ical.properties += prodId.withUserAgents(userAgents) val dtStart = dtStart ?: throw InvalidCalendarException("Won't generate event without start time") @@ -374,4 +380,5 @@ data class Event( return e } } + } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt b/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt index a8f4b1cc..7c9a51c5 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt @@ -56,20 +56,29 @@ open class ICalendar { get() = Logger.getLogger(ICalendar::class.java.name) // known iCalendar properties + const val CALENDAR_NAME = "X-WR-CALNAME" const val CALENDAR_COLOR = "X-APPLE-CALENDAR-COLOR" + + // PRODID generation + /** - * Default PRODID used when generating iCalendars. If you want another value, set it - * statically before writing the first iCalendar. + * Extends the given `PRODID` with the user agents (typically calendar app name and version). + * This way the `PRODID` does not only identify the app that actually produces the iCalendar, + * but also the used front-end app, which may be helpful when debugging the iCalendar. + * + * @param userAgents list of involved user agents + * (preferably in `package name/version` format, for instance `com.example.mycalendar/1.0`) + * + * @return original `PRODID` with user agents in parentheses */ - var prodId = ProdId("+//IDN bitfire.at//ical4android") - - fun prodId(userAgents: List): ProdId = + fun ProdId.withUserAgents(userAgents: List) = if (userAgents.isEmpty()) - prodId + this else - ProdId(prodId.value + " (" + userAgents.joinToString(",") + ")") + ProdId(value + " (${userAgents.joinToString(", ")})") + // parser @@ -373,6 +382,4 @@ open class ICalendar { uid = UUID.randomUUID().toString() } - fun prodId(): ProdId = prodId(userAgents) - } \ No newline at end of file diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollection.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollection.kt index 9f12ba9b..0d2f54a2 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollection.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollection.kt @@ -19,6 +19,7 @@ import at.techbee.jtx.JtxContract.asSyncAdapter import net.fortuna.ical4j.model.Calendar import net.fortuna.ical4j.model.component.VJournal import net.fortuna.ical4j.model.component.VToDo +import net.fortuna.ical4j.model.property.ProdId import net.fortuna.ical4j.model.property.Version import java.util.LinkedList import java.util.logging.Level @@ -243,9 +244,11 @@ open class JtxCollection(val account: Account, /** + * @param prodId `PRODID` that identifies the app + * * @return a string with all JtxICalObjects within the collection as iCalendar */ - fun getICSForCollection(): String { + fun getICSForCollection(prodId: ProdId): String { client.query( JtxContract.JtxICalObject.CONTENT_URI.asSyncAdapter(account), null, @@ -257,12 +260,12 @@ open class JtxCollection(val account: Account, val ical = Calendar() ical.properties += Version.VERSION_2_0 - ical.properties += ICalendar.prodId + ical.properties += prodId while (cursor?.moveToNext() == true) { val jtxIcalObject = JtxICalObject(this) jtxIcalObject.populateFromContentValues(cursor.toValues()) - val singleICS = jtxIcalObject.getICalendarFormat() + val singleICS = jtxIcalObject.getICalendarFormat(prodId) singleICS?.components?.forEach { component -> if(component is VToDo || component is VJournal) ical.components += component diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt index 40f0ead7..916421af 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt @@ -12,6 +12,7 @@ import android.net.ParseException import android.net.Uri import android.os.ParcelFileDescriptor import android.util.Base64 +import at.bitfire.ical4android.ICalendar.Companion.withUserAgents import at.bitfire.ical4android.util.MiscUtils.toValues import at.bitfire.synctools.storage.BatchOperation import at.bitfire.synctools.storage.JtxBatchOperation @@ -629,12 +630,15 @@ open class JtxICalObject( /** * Takes the current JtxICalObject and transforms it to a Calendar (ical4j) + * + * @param prodId `PRODID` that identifies the app + * * @return The current JtxICalObject transformed into a ical4j Calendar */ - fun getICalendarFormat(): Calendar? { + fun getICalendarFormat(prodId: ProdId): Calendar? { val ical = Calendar() ical.properties += Version.VERSION_2_0 - ical.properties += ICalendar.prodId(listOf(TaskProvider.ProviderName.JtxBoard.packageName)) + ical.properties += prodId.withUserAgents(listOf(TaskProvider.ProviderName.JtxBoard.packageName)) val calComponent = when (component) { JtxContract.JtxICalObject.Component.VTODO.name -> VToDo(true /* generates DTSTAMP */) @@ -731,10 +735,12 @@ open class JtxICalObject( /** * Takes the current JtxICalObject, transforms it to an iCalendar and writes it in an OutputStream + * * @param [os] OutputStream where iCalendar should be written to + * @param prodId `PRODID` that identifies the app */ - fun write(os: OutputStream) { - CalendarOutputter(false).output(this.getICalendarFormat(), os) + fun write(os: OutputStream, prodId: ProdId) { + CalendarOutputter(false).output(this.getICalendarFormat(prodId), os) } /** diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt index 40638860..6274904a 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt @@ -194,10 +194,16 @@ data class Task( } - fun write(os: OutputStream) { + /** + * Generates an iCalendar from the Task. + * + * @param os stream that the iCalendar is written to + * @param prodId `PRODID` that identifies the app + */ + fun write(os: OutputStream, prodId: ProdId) { val ical = Calendar() ical.properties += Version.VERSION_2_0 - ical.properties += prodId() + ical.properties += prodId.withUserAgents(userAgents) val vTodo = VToDo(true /* generates DTSTAMP */) ical.components += vTodo 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 diff --git a/lib/src/main/kotlin/at/bitfire/vcard4android/Contact.kt b/lib/src/main/kotlin/at/bitfire/vcard4android/Contact.kt index 77573b98..3642f52d 100644 --- a/lib/src/main/kotlin/at/bitfire/vcard4android/Contact.kt +++ b/lib/src/main/kotlin/at/bitfire/vcard4android/Contact.kt @@ -13,11 +13,20 @@ import com.google.common.base.MoreObjects import ezvcard.VCardVersion import ezvcard.io.json.JCardReader import ezvcard.io.text.VCardReader -import ezvcard.property.* +import ezvcard.property.Address +import ezvcard.property.Anniversary +import ezvcard.property.Birthday +import ezvcard.property.Email +import ezvcard.property.Impp +import ezvcard.property.Nickname +import ezvcard.property.Organization +import ezvcard.property.Related +import ezvcard.property.Telephone +import ezvcard.property.Url import java.io.IOException import java.io.OutputStream import java.io.Reader -import java.util.* +import java.util.LinkedList /** * Data class for a contact; between vCards and the Android contacts provider. @@ -77,10 +86,6 @@ data class Contact( ) { companion object { - // productID (if set) will be used to generate a PRODID property. - // You may set this statically from the calling application. - var productID: String? = null - const val DATE_PARAMETER_OMIT_YEAR = "X-APPLE-OMIT-YEAR" const val DATE_PARAMETER_OMIT_YEAR_DEFAULT = 1604 @@ -120,14 +125,14 @@ data class Contact( @Throws(IOException::class) - fun writeJCard(os: OutputStream) { - val generator = ContactWriter.fromContact(this, VCardVersion.V4_0) + fun writeJCard(os: OutputStream, productId: String) { + val generator = ContactWriter(this, VCardVersion.V4_0, productId) generator.writeCard(os, true) } @Throws(IOException::class) - fun writeVCard(vCardVersion: VCardVersion, os: OutputStream) { - val generator = ContactWriter.fromContact(this, vCardVersion) + fun writeVCard(vCardVersion: VCardVersion, os: OutputStream, productId: String) { + val generator = ContactWriter(this, vCardVersion, productId) generator.writeCard(os, false) } diff --git a/lib/src/main/kotlin/at/bitfire/vcard4android/ContactWriter.kt b/lib/src/main/kotlin/at/bitfire/vcard4android/ContactWriter.kt index 2cfc0450..826563f4 100644 --- a/lib/src/main/kotlin/at/bitfire/vcard4android/ContactWriter.kt +++ b/lib/src/main/kotlin/at/bitfire/vcard4android/ContactWriter.kt @@ -31,6 +31,7 @@ import ezvcard.property.DateOrTimeProperty import ezvcard.property.Kind import ezvcard.property.Member import ezvcard.property.Photo +import ezvcard.property.ProductId import ezvcard.property.Related import ezvcard.property.Revision import ezvcard.property.StructuredName @@ -47,15 +48,16 @@ import java.util.logging.Logger * to the vCard that is actually sent to the server. * * Properties which are not supported by the target vCard version have to be converted appropriately. + * + * @param contact contact data to be converted into a vCard + * @param version vCard version to generate + * @param productId product ID that identifies your app (will be used as PRODID; ez-vcard version will be appended) */ -class ContactWriter private constructor(val contact: Contact, val version: VCardVersion) { - - companion object { - - fun fromContact(contact: Contact, version: VCardVersion) = - ContactWriter(contact, version) - - } +class ContactWriter( + val contact: Contact, + val version: VCardVersion, + val productId: String +) { private val unknownProperties = LinkedList() val vCard = VCard() @@ -73,8 +75,8 @@ class ContactWriter private constructor(val contact: Contact, val version: VCard } private fun addProperties() { + vCard.productId = ProductId("$productId (ez-vcard/${Ezvcard.VERSION})") contact.uid?.let { vCard.uid = Uid(it) } - Contact.productID?.let { vCard.setProductId(it) } addKindAndMembers() addFormattedName() @@ -355,7 +357,7 @@ class ContactWriter private constructor(val contact: Contact, val version: VCard val writer = if (jCard) JCardWriter(stream).apply { - isAddProdId = Contact.productID == null + isAddProdId = false // we handle PRODID ourselves registerCustomScribes() // allow properties that are not defined in this vCard version @@ -363,7 +365,7 @@ class ContactWriter private constructor(val contact: Contact, val version: VCard } else VCardWriter(stream, version).apply { - isAddProdId = Contact.productID == null + isAddProdId = false // we handle PRODID ourselves registerCustomScribes() /* include trailing semicolons for maximum compatibility diff --git a/lib/src/test/kotlin/at/bitfire/vcard4android/Constants.kt b/lib/src/test/kotlin/at/bitfire/vcard4android/Constants.kt new file mode 100644 index 00000000..d5c9f939 --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/vcard4android/Constants.kt @@ -0,0 +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 + */ + +package at.bitfire.vcard4android + +val testProductId = "bitfireAT/synctools test" \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/vcard4android/ContactTest.kt b/lib/src/test/kotlin/at/bitfire/vcard4android/ContactTest.kt index eb575700..71fd92cc 100644 --- a/lib/src/test/kotlin/at/bitfire/vcard4android/ContactTest.kt +++ b/lib/src/test/kotlin/at/bitfire/vcard4android/ContactTest.kt @@ -40,7 +40,7 @@ class ContactTest { private fun regenerate(c: Contact, vCardVersion: VCardVersion): Contact { val os = ByteArrayOutputStream() - c.writeVCard(vCardVersion, os) + c.writeVCard(vCardVersion, os, testProductId) return Contact.fromReader(InputStreamReader(ByteArrayInputStream(os.toByteArray()), Charsets.UTF_8), false,null).first() } @@ -85,7 +85,7 @@ class ContactTest { // TEL assertEquals(2, c.phoneNumbers.size) - var phone = c.phoneNumbers.first + var phone = c.phoneNumbers.first() assertEquals("Useless", phone.label) assertTrue(phone.property.types.contains(TelephoneType.VOICE)) assertTrue(phone.property.types.contains(TelephoneType.HOME)) @@ -99,7 +99,7 @@ class ContactTest { // EMAIL assertEquals(2, c.emails.size) - var email = c.emails.first + var email = c.emails.first() assertNull(email.label) assertTrue(email.property.types.contains(EmailType.HOME)) assertTrue(email.property.types.contains(EmailType.PREF)) @@ -120,7 +120,7 @@ class ContactTest { // IMPP assertEquals(3, c.impps.size) - var impp = c.impps.first + var impp = c.impps.first() assertEquals("MyIM", impp.label) assertTrue(impp.property.types.contains(ImppType.PERSONAL)) assertTrue(impp.property.types.contains(ImppType.MOBILE)) @@ -146,7 +146,7 @@ class ContactTest { // ADR assertEquals(2, c.addresses.size) - var addr = c.addresses.first + var addr = c.addresses.first() assertNull(addr.label) assertTrue(addr.property.types.contains(AddressType.WORK)) assertTrue(addr.property.types.contains(AddressType.POSTAL)) @@ -203,14 +203,14 @@ class ContactTest { assertEquals(LocalDate.of(2014, 8, 12), c.anniversary!!.date) // X-ABDATE assertEquals(1, c.customDates.size) - c.customDates.first.also { date -> + c.customDates.first().also { date -> assertEquals("Custom Date", date.label) assertEquals(LocalDate.of(2021, 7, 29), date.property.date) } // RELATED assertEquals(2, c.relations.size) - var rel = c.relations.first + var rel = c.relations.first() assertTrue(rel.types.contains(RelatedType.CO_WORKER)) assertTrue(rel.types.contains(RelatedType.CRUSH)) assertEquals("Ägidius", rel.text) @@ -229,19 +229,19 @@ class ContactTest { val c = regenerate(parseContact("allfields-vcard3.vcf"), VCardVersion.V4_0) // let's check only things that should be different when VCard 4.0 is generated - val phone = c.phoneNumbers.first.property + val phone = c.phoneNumbers.first().property assertFalse(phone.types.contains(TelephoneType.PREF)) assertNotNull(phone.pref) - val email = c.emails.first.property + val email = c.emails.first().property assertFalse(email.types.contains(EmailType.PREF)) assertNotNull(email.pref) - val impp = c.impps.first.property + val impp = c.impps.first().property assertFalse(impp.types.contains(ImppType.PREF)) assertNotNull(impp.pref) - val addr = c.addresses.first.property + val addr = c.addresses.first().property assertFalse(addr.types.contains(AddressType.PREF)) assertNotNull(addr.pref) } diff --git a/lib/src/test/kotlin/at/bitfire/vcard4android/ContactWriterTest.kt b/lib/src/test/kotlin/at/bitfire/vcard4android/ContactWriterTest.kt index 5b817f0b..301e1883 100644 --- a/lib/src/test/kotlin/at/bitfire/vcard4android/ContactWriterTest.kt +++ b/lib/src/test/kotlin/at/bitfire/vcard4android/ContactWriterTest.kt @@ -6,24 +6,44 @@ package at.bitfire.vcard4android -import at.bitfire.vcard4android.property.* +import at.bitfire.vcard4android.property.CustomType +import at.bitfire.vcard4android.property.XAbDate +import at.bitfire.vcard4android.property.XAbLabel +import at.bitfire.vcard4android.property.XAbRelatedNames +import at.bitfire.vcard4android.property.XAddressBookServerKind +import at.bitfire.vcard4android.property.XAddressBookServerMember +import at.bitfire.vcard4android.property.XPhoneticFirstName +import at.bitfire.vcard4android.property.XPhoneticLastName +import at.bitfire.vcard4android.property.XPhoneticMiddleName +import ezvcard.Ezvcard import ezvcard.VCard import ezvcard.VCardVersion import ezvcard.parameter.ImageType import ezvcard.parameter.RelatedType -import ezvcard.property.* +import ezvcard.property.Address +import ezvcard.property.Anniversary +import ezvcard.property.Birthday +import ezvcard.property.Email +import ezvcard.property.Impp +import ezvcard.property.Kind +import ezvcard.property.Nickname +import ezvcard.property.Organization +import ezvcard.property.Photo +import ezvcard.property.Related +import ezvcard.property.Revision +import ezvcard.property.StructuredName +import ezvcard.property.Telephone +import ezvcard.property.Url import ezvcard.util.PartialDate -import org.junit.Assert.* +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import java.io.ByteArrayOutputStream import java.net.URI -import java.time.Instant import java.time.LocalDate -import java.time.LocalDateTime -import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime -import java.util.* class ContactWriterTest { @@ -451,7 +471,7 @@ class ContactWriterTest { values.add("nick1") }) } - assertEquals(3 /* NICK + REV + FN */, vCard.properties.size) + assertEquals(4 /* PRODID + NICK + REV + FN */, vCard.properties.size) assertEquals("nick1", vCard.nickname.values.first()) } @@ -462,7 +482,7 @@ class ContactWriterTest { values.add("nick1") }, "label1") } - assertEquals(4 /* NICK + X-ABLABEL + FN + REV */, vCard.properties.size) + assertEquals(5 /* PRODID + NICK + X-ABLABEL + FN + REV */, vCard.properties.size) vCard.nickname.apply { assertEquals("nick1", values.first()) assertEquals("item1", group) @@ -483,7 +503,7 @@ class ContactWriterTest { values.add("nick1") }, "label1") } - assertEquals(5 /* X-TEST + NICK + X-ABLABEL + FN + REV */, vCard.properties.size) + assertEquals(6 /* PRODID + X-TEST + NICK + X-ABLABEL + FN + REV */, vCard.properties.size) vCard.nickname.apply { assertEquals("nick1", values.first()) assertEquals("item2", group) @@ -497,7 +517,7 @@ class ContactWriterTest { @Test fun testRewritePartialDate_vCard3_Date() { - val generator = ContactWriter.fromContact(Contact(), VCardVersion.V3_0) + val generator = ContactWriter(Contact(), VCardVersion.V3_0, testProductId) val date = Birthday(LocalDate.of(121, 6, 30)) generator.rewritePartialDate(date) assertEquals(LocalDate.of(121, 6, 30), date.date) @@ -506,7 +526,7 @@ class ContactWriterTest { @Test fun testRewritePartialDate_vCard4_Date() { - val generator = ContactWriter.fromContact(Contact(), VCardVersion.V4_0) + val generator = ContactWriter(Contact(), VCardVersion.V4_0, testProductId) val date = Birthday(LocalDate.of(121, 6, 30)) generator.rewritePartialDate(date) assertEquals(LocalDate.of(121, 6, 30), date.date) @@ -516,7 +536,7 @@ class ContactWriterTest { @Test fun testRewritePartialDate_vCard3_PartialDateWithYear() { - val generator = ContactWriter.fromContact(Contact(), VCardVersion.V3_0) + val generator = ContactWriter(Contact(), VCardVersion.V3_0, testProductId) val date = Birthday(PartialDate.parse("20210730")) generator.rewritePartialDate(date) assertEquals(LocalDate.of(2021, 7, 30), date.date) @@ -526,7 +546,7 @@ class ContactWriterTest { @Test fun testRewritePartialDate_vCard4_PartialDateWithYear() { - val generator = ContactWriter.fromContact(Contact(), VCardVersion.V4_0) + val generator = ContactWriter(Contact(), VCardVersion.V4_0, testProductId) val date = Birthday(PartialDate.parse("20210730")) generator.rewritePartialDate(date) assertNull(date.date) @@ -536,7 +556,7 @@ class ContactWriterTest { @Test fun testRewritePartialDate_vCard3_PartialDateWithoutYear() { - val generator = ContactWriter.fromContact(Contact(), VCardVersion.V3_0) + val generator = ContactWriter(Contact(), VCardVersion.V3_0, testProductId) val date = Birthday(PartialDate.parse("--0730")) generator.rewritePartialDate(date) assertEquals(LocalDate.of(1604, 7, 30), date.date) @@ -547,7 +567,7 @@ class ContactWriterTest { @Test fun testRewritePartialDate_vCard4_PartialDateWithoutYear() { - val generator = ContactWriter.fromContact(Contact(), VCardVersion.V4_0) + val generator = ContactWriter(Contact(), VCardVersion.V4_0, testProductId) val date = Birthday(PartialDate.parse("--0730")) generator.rewritePartialDate(date) assertNull(date.date) @@ -558,27 +578,30 @@ class ContactWriterTest { @Test fun testWriteJCard() { - val generator = ContactWriter.fromContact(Contact(), VCardVersion.V4_0) + val generator = ContactWriter(Contact(), VCardVersion.V4_0, testProductId) generator.vCard.revision = Revision( ZonedDateTime.of(2021, 7, 30, 1, 2, 3, 0, ZoneOffset.UTC) ) val stream = ByteArrayOutputStream() generator.writeCard(stream, true) - assertEquals("[\"vcard\",[[\"version\",{},\"text\",\"4.0\"],[\"prodid\",{},\"text\",\"ez-vcard 0.12.1\"],[\"fn\",{},\"text\",\"\"],[\"rev\",{},\"timestamp\",\"2021-07-30T01:02:03+00:00\"]]]", stream.toString()) + assertEquals( + "[\"vcard\",[[\"version\",{},\"text\",\"4.0\"],[\"prodid\",{},\"text\",\"$testProductId (ez-vcard/${Ezvcard.VERSION})\"],[\"fn\",{},\"text\",\"\"],[\"rev\",{},\"timestamp\",\"2021-07-30T01:02:03+00:00\"]]]", + stream.toString() + ) } @Test fun testWriteVCard() { - val generator = ContactWriter.fromContact(Contact(), VCardVersion.V4_0) + val generator = ContactWriter(Contact(), VCardVersion.V4_0, testProductId) generator.vCard.revision = Revision(ZonedDateTime.of(2021, 7, 30, 1, 2, 3, 0, ZoneOffset.UTC)) val stream = ByteArrayOutputStream() generator.writeCard(stream, false) assertEquals("BEGIN:VCARD\r\n" + "VERSION:4.0\r\n" + - "PRODID:ez-vcard 0.12.1\r\n" + + "PRODID:$testProductId (ez-vcard/${Ezvcard.VERSION})\r\n" + "FN:\r\n" + "REV:20210730T010203+0000\r\n" + "END:VCARD\r\n", stream.toString()) @@ -594,9 +617,8 @@ class ContactWriterTest { country = "Line2" }) } - ContactWriter - .fromContact(contact, VCardVersion.V4_0) - .writeCard(stream, false) + ContactWriter(contact, VCardVersion.V4_0, testProductId) + .writeCard(stream, false) assertTrue(stream.toString().contains("ADR;LABEL=\"Li^^ne 1,1 - ^' -\":;;Line1;;;;Line2")) } @@ -606,7 +628,7 @@ class ContactWriterTest { private fun generate(version: VCardVersion = VCardVersion.V4_0, prepare: Contact.() -> Unit): VCard { val contact = Contact() contact.run(prepare) - return ContactWriter.fromContact(contact, version).vCard + return ContactWriter(contact, version, testProductId).vCard } } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index a0e20df4..bbf8ff3a 100644 --- a/settings.gradle.kts +++ b/settings.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 + */ + pluginManagement { repositories { google() @@ -14,6 +20,5 @@ dependencyResolutionManagement { } } -rootProject.name = "root" -include(":lib") -project(":lib").name = "synctools" \ No newline at end of file +rootProject.name = "synctools" +include(":lib") \ No newline at end of file