From 60090d53604e32fa2a8c008348f1eeedcb12a6ed Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 26 Mar 2026 11:07:29 +0100 Subject: [PATCH 1/6] Fix JtxICalObject update mistakes --- .../bitfire/ical4android/JtxICalObjectTest.kt | 16 +- .../at/bitfire/ical4android/JtxCollection.kt | 5 +- .../at/bitfire/ical4android/JtxICalObject.kt | 461 +++++++++--------- 3 files changed, 229 insertions(+), 253 deletions(-) diff --git a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt index f10b5ad7..d92dc7be 100644 --- a/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt +++ b/lib/src/androidTest/kotlin/at/bitfire/ical4android/JtxICalObjectTest.kt @@ -15,7 +15,7 @@ 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.synctools.icalendar.ICalendarParser import at.bitfire.synctools.test.GrantPermissionOrSkipRule import at.techbee.jtx.JtxContract import at.techbee.jtx.JtxContract.JtxICalObject @@ -35,6 +35,7 @@ import org.junit.Rule import org.junit.Test import java.io.ByteArrayOutputStream import java.io.InputStreamReader +import kotlin.jvm.optionals.getOrNull class JtxICalObjectTest { @@ -837,17 +838,16 @@ class JtxICalObjectTest { //assertEquals(iCalIn.components[0].getProperty(Component.VTODO), iCalOut.components[0].getProperty(Component.VTODO)) // there should only be one component for VJOURNAL and VTODO! - TODO("ical4j 4.x") - /*for(i in 0 until iCalIn.components.size) { + for(i in 0 until iCalIn.componentList.all.size) { - iCalIn.components[i].properties.forEach { inProp -> + iCalIn.componentList.all[i].propertyList.all.forEach { inProp -> if(inProp.name == "DTSTAMP" || exceptions?.contains(inProp.name) == true) return@forEach - val outProp = iCalOut.components[i].properties.getProperty(inProp.name) + val outProp = iCalOut.componentList.all[i].propertyList.getFirst(inProp.name)?.getOrNull() assertEquals(inProp, outProp) } - }*/ + } } @@ -861,7 +861,7 @@ class JtxICalObjectTest { val stream = javaClass.classLoader!!.getResourceAsStream(filename) val reader = InputStreamReader(stream, Charsets.UTF_8) - val iCalIn = ICalendar.fromReader(reader) + val iCalIn = ICalendarParser().parse(reader) stream.close() reader.close() @@ -885,7 +885,7 @@ class JtxICalObjectTest { iCalObject[0].write(os, testProdId) - val iCalOut = ICalendar.fromReader(os.toByteArray().inputStream().reader()) + val iCalOut = ICalendarParser().parse(os.toByteArray().inputStream().reader()) stream.close() reader.close() diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollection.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollection.kt index f3081ec1..dbda65c9 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollection.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxCollection.kt @@ -18,7 +18,6 @@ import at.bitfire.synctools.storage.toContentValues import at.techbee.jtx.JtxContract import at.techbee.jtx.JtxContract.asSyncAdapter import net.fortuna.ical4j.model.Calendar -import net.fortuna.ical4j.model.component.CalendarComponent import net.fortuna.ical4j.model.component.VJournal import net.fortuna.ical4j.model.component.VToDo import net.fortuna.ical4j.model.property.ProdId @@ -268,9 +267,9 @@ open class JtxCollection(val account: Account, val jtxIcalObject = JtxICalObject(this) jtxIcalObject.populateFromContentValues(cursor.toContentValues()) val singleICS = jtxIcalObject.getICalendarFormat(prodId) - singleICS?.getComponents()?.forEach { component -> + singleICS?.componentList?.all?.forEach { component -> if(component is VToDo || component is VJournal) - ical.getComponents() += component + ical += component } } return ical.toString() diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt index cce13445..40019a2a 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt @@ -13,10 +13,13 @@ import android.os.ParcelFileDescriptor import android.util.Base64 import androidx.core.content.contentValuesOf import at.bitfire.ical4android.ICalendar.Companion.withUserAgents +import at.bitfire.ical4android.util.DateUtils.toEpochMilli +import at.bitfire.ical4android.util.DateUtils.toLocalDate import at.bitfire.synctools.exception.InvalidICalendarException import at.bitfire.synctools.icalendar.Css3Color import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDates +import at.bitfire.synctools.icalendar.ICalendarParser import at.bitfire.synctools.icalendar.plusAssign import at.bitfire.synctools.storage.BatchOperation import at.bitfire.synctools.storage.JtxBatchOperation @@ -33,7 +36,6 @@ import net.fortuna.ical4j.model.ParameterList import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.PropertyList import net.fortuna.ical4j.model.TextList -import net.fortuna.ical4j.model.component.CalendarComponent import net.fortuna.ical4j.model.component.VAlarm import net.fortuna.ical4j.model.component.VJournal import net.fortuna.ical4j.model.component.VToDo @@ -301,26 +303,28 @@ open class JtxICalObject( reader: Reader, collection: JtxCollection ): List { - val ical = ICalendar.fromReader(reader) + val ical = ICalendarParser().parse(reader) val iCalObjectList = mutableListOf() - ical.getComponents().forEach { component -> + ical.componentList.all.forEach { component -> val iCalObject = JtxICalObject(collection) when(component) { is VToDo -> { iCalObject.component = JtxContract.JtxICalObject.Component.VTODO.name - if (component.uid.isPresent) - iCalObject.uid = component.uid.get().value // generated UID is overwritten here (if present) + component.uid.getOrNull()?.let { uid -> + iCalObject.uid = uid.value // generated UID is overwritten here (if present) + } extractProperties(iCalObject, component.propertyList) extractVAlarms(iCalObject, component.componentList) // accessing the components needs an explicit type iCalObjectList.add(iCalObject) } is VJournal -> { iCalObject.component = JtxContract.JtxICalObject.Component.VJOURNAL.name - if (component.uid.isPresent) - iCalObject.uid = component.uid.get().value + component.uid.getOrNull()?.let { uid -> + iCalObject.uid = uid.value + } extractProperties(iCalObject, component.propertyList) extractVAlarms(iCalObject, component.componentList) // accessing the components needs an explicit type iCalObjectList.add(iCalObject) @@ -388,8 +392,8 @@ open class JtxICalObject( for (prop in properties.all) { when (prop) { is Sequence -> iCalObject.sequence = prop.sequenceNo.toLong() - is Created -> iCalObject.created = prop.date.toEpochMilli() // Instant. No need to normalize - is LastModified -> iCalObject.lastModified = prop.date.toEpochMilli() // Instant. No need to normalize + is Created -> iCalObject.created = prop.normalizedDate().toEpochMilli() + is LastModified -> iCalObject.lastModified = prop.normalizedDate().toEpochMilli() is Summary -> iCalObject.summary = prop.value is Location -> { iCalObject.location = prop.value @@ -428,7 +432,8 @@ open class JtxICalObject( is Duration -> iCalObject.duration = prop.value is DtStart<*> -> { - iCalObject.dtstart = prop.normalizedDate().toEpochMilli() + val temporal = prop.normalizedDate() + iCalObject.dtstart = temporal.toEpochMilli() iCalObject.dtstartTimezone = prop.normalizedDate().getTimeZoneId() } @@ -441,25 +446,24 @@ open class JtxICalObject( is RRule<*> -> iCalObject.rrule = prop.value is RDate<*> -> { - val rdateList: MutableList = if(iCalObject.rdate.isNullOrEmpty()) - mutableListOf() - else - JtxContract.getLongListFromString(iCalObject.rdate!!) - prop.normalizedDates().forEach { date -> - date.toEpochMilli()?.let { rdateList.add(it) } - } - iCalObject.rdate = rdateList.toTypedArray().joinToString(separator = ",") + iCalObject.rdate = buildList { + if(!iCalObject.rdate.isNullOrEmpty()) + add(JtxContract.getLongListFromString(iCalObject.rdate!!)) + prop.normalizedDates().forEach { date -> + add(date.toEpochMilli()) + } + }.toTypedArray().joinToString(separator = ",") } is ExDate<*> -> { - val exdateList: MutableList = if(iCalObject.exdate.isNullOrEmpty()) - mutableListOf() - else - JtxContract.getLongListFromString(iCalObject.exdate!!) - prop.normalizedDates().forEach { date -> - date.toEpochMilli()?.let { exdateList.add(it) } - } - iCalObject.exdate = exdateList.toTypedArray().joinToString(separator = ",") + iCalObject.exdate = buildList { + if(!iCalObject.exdate.isNullOrEmpty()) + add(JtxContract.getLongListFromString(iCalObject.exdate!!)) + prop.normalizedDates().forEach { date -> + add(date.toEpochMilli()) + } + }.toTypedArray().joinToString(separator = ",") } + is RecurrenceId<*> -> { iCalObject.recurid = prop.toString() iCalObject.recuridTimezone = prop.normalizedDate().getTimeZoneId() @@ -479,8 +483,10 @@ open class JtxICalObject( this.altrep = prop.getParameter(Parameter.ALTREP)?.getOrNull()?.value // remove the known parameter - prop.parameterList?.removeAll(Parameter.LANGUAGE) - prop.parameterList?.removeAll(Parameter.ALTREP) + prop.removeAll( + Parameter.LANGUAGE, + Parameter.ALTREP + ) // save unknown parameters in the other field this.other = JtxContract.getJsonStringFromXParameters(prop.parameterList) @@ -525,7 +531,7 @@ open class JtxICalObject( this.reltype = prop.getParameter(RelType.RELTYPE)?.getOrNull()?.value ?: JtxContract.JtxRelatedto.Reltype.PARENT.name // remove the known parameter - prop.parameterList?.removeAll(RelType.RELTYPE) + prop.removeAll(RelType.RELTYPE) // save unknown parameters in the other field this.other = JtxContract.getJsonStringFromXParameters(prop.parameterList) @@ -549,17 +555,19 @@ open class JtxICalObject( this.sentby = prop.getParameter(Parameter.SENT_BY)?.getOrNull()?.value // remove all known parameters so that only unknown parameters remain - prop.parameterList?.removeAll(Parameter.CN) - prop.parameterList?.removeAll(Parameter.DELEGATED_TO) - prop.parameterList?.removeAll(Parameter.DELEGATED_FROM) - prop.parameterList?.removeAll(Parameter.CUTYPE) - prop.parameterList?.removeAll(Parameter.DIR) - prop.parameterList?.removeAll(Parameter.LANGUAGE) - prop.parameterList?.removeAll(Parameter.MEMBER) - prop.parameterList?.removeAll(Parameter.PARTSTAT) - prop.parameterList?.removeAll(Parameter.ROLE) - prop.parameterList?.removeAll(Parameter.RSVP) - prop.parameterList?.removeAll(Parameter.SENT_BY) + prop.removeAll( + Parameter.CN, + Parameter.DELEGATED_TO, + Parameter.DELEGATED_FROM, + Parameter.CUTYPE, + Parameter.DIR, + Parameter.LANGUAGE, + Parameter.MEMBER, + Parameter.PARTSTAT, + Parameter.ROLE, + Parameter.RSVP, + Parameter.SENT_BY + ) // save unknown parameters in the other field this.other = JtxContract.getJsonStringFromXParameters(prop.parameterList) @@ -567,28 +575,29 @@ open class JtxICalObject( ) } is net.fortuna.ical4j.model.property.Organizer -> { - iCalObject.organizer = Organizer().apply { - this.caladdress = prop.calAddress.toString() - this.cn = prop.getParameter(Parameter.CN)?.getOrNull()?.value - this.dir = prop.getParameter(Parameter.DIR)?.getOrNull()?.value - this.language = prop.getParameter(Parameter.LANGUAGE)?.getOrNull()?.value - this.sentby = prop.getParameter(Parameter.SENT_BY)?.getOrNull()?.value - - // remove all known parameters so that only unknown parameters remain - prop.parameterList?.removeAll(Parameter.CN) - prop.parameterList?.removeAll(Parameter.DIR) - prop.parameterList?.removeAll(Parameter.LANGUAGE) - prop.parameterList?.removeAll(Parameter.SENT_BY) - - // save unknown parameters in the other field - this.other = JtxContract.getJsonStringFromXParameters(prop.parameterList) + iCalObject.organizer = Organizer().apply { + this.caladdress = prop.calAddress.toString() + this.cn = prop.getParameter(Parameter.CN)?.getOrNull()?.value + this.dir = prop.getParameter(Parameter.DIR)?.getOrNull()?.value + this.language = prop.getParameter(Parameter.LANGUAGE)?.getOrNull()?.value + this.sentby = prop.getParameter(Parameter.SENT_BY)?.getOrNull()?.value + + // remove all known parameters so that only unknown parameters remain + prop.removeAll( + Parameter.CN, + Parameter.DIR, + Parameter.LANGUAGE, + Parameter.SENT_BY + ) + + // save unknown parameters in the other field + this.other = JtxContract.getJsonStringFromXParameters(prop.parameterList) } } is Uid -> iCalObject.uid = prop.value //is Uid, - is ProdId, is DtStamp -> { - } /* don't save these as unknown properties */ + is ProdId, is DtStamp -> {} /* don't save these as unknown properties */ else -> when(prop.name) { X_PROP_COMPLETEDTIMEZONE -> iCalObject.completedTimezone = prop.value X_PROP_XSTATUS -> iCalObject.xstatus = prop.value @@ -613,7 +622,7 @@ open class JtxICalObject( } //previously due was dropped, now reduced to a warning, see also https://github.com/bitfireAT/ical4android/issues/70 - if ( iCalObject.dtstart != null && iCalObject.due != null && iCalObject.due!! < iCalObject.dtstart!!) + if (iCalObject.dtstart != null && iCalObject.due != null && iCalObject.due!! < iCalObject.dtstart!!) logger.warning("Found invalid DUE < DTSTART") } @@ -622,27 +631,16 @@ open class JtxICalObject( iCalObject.duration = null } } - private fun Temporal.toEpochMilli(): Long? = when (this) { - is ZonedDateTime -> this.toInstant().toEpochMilli() // Calculate from contained time zone - is Instant -> this.toEpochMilli() // Calculated from UTC time - is LocalDateTime -> this - .atZone(ZoneId.systemDefault()) // Use system default time zone to interpret as local time - .toInstant() - .toEpochMilli() - is LocalDate -> this - .atStartOfDay(ZoneOffset.UTC) // Use start of day for local date without time (ie. local all-day events) - .toInstant() - .toEpochMilli() - else -> { - logger.warning("Ignoring unsupported temporal type: ${this::class}") - null - } - } + private fun Temporal.getTimeZoneId(): String? = when (this) { - is ZonedDateTime -> this.zone.id // We got a timezone - is Instant -> ZoneOffset.UTC.id // Instant is a point on the UTC timeline - is LocalDateTime -> null // Timezone unknown => floating time - is LocalDate -> TZ_ALLDAY // Without time, it is considered all-day + is ZonedDateTime -> + this.zone.id // We got a timezone + is Instant -> + ZoneOffset.UTC.id // Instant is a point on the UTC timeline + is LocalDateTime -> + null // Timezone unknown => floating time + is LocalDate -> + TZ_ALLDAY // Without time, it is considered all-day else -> { logger.warning("Ignoring unsupported temporal type: ${this::class}") null @@ -667,8 +665,8 @@ open class JtxICalObject( JtxContract.JtxICalObject.Component.VJOURNAL.name -> VJournal(true /* generates DTSTAMP */) else -> return null } + calComponent.propertyList = addProperties(calComponent.propertyList) ical += calComponent - addProperties(calComponent.propertyList) alarms.forEach { alarm -> @@ -691,9 +689,9 @@ open class JtxICalObject( // Add the RELATED parameter if present alarm.triggerRelativeTo?.let { if(it == JtxContract.JtxAlarm.AlarmRelativeTo.START.name) - this.parameterList.add(Related.START) + this.add(Related.START) if(it == JtxContract.JtxAlarm.AlarmRelativeTo.END.name) - this.parameterList.add(Related.END) + this.add(Related.END) } } catch (e: DateTimeParseException) { logger.log(Level.WARNING, "Could not parse Trigger duration as Duration.", e) @@ -762,73 +760,70 @@ open class JtxICalObject( * This function maps the current JtxICalObject to a iCalendar property list * @param [props] The PropertyList where the properties should be added */ - private fun addProperties(props: PropertyList) { - uid.let { props += Uid(it) } - sequence.let { props += Sequence(it.toInt()) } + private fun addProperties(props: PropertyList): PropertyList { + val propSet = mutableSetOf() + uid.let { propSet += Uid(it) } + sequence.let { propSet += Sequence(it.toInt()) } - created.let { props += Created(Instant.ofEpochMilli(it)) } - lastModified.let { props += LastModified(Instant.ofEpochMilli(it))} + created.let { propSet += Created(Instant.ofEpochMilli(it)) } + lastModified.let { propSet += LastModified(Instant.ofEpochMilli(it))} - summary.let { props += Summary(it) } - description?.let { props += Description(it) } + summary.let { propSet += Summary(it) } + description?.let { propSet += Description(it) } location?.let { location -> val loc = Location(location) locationAltrep?.let { locationAltrep -> - loc.parameterList.add(AltRep(locationAltrep)) + loc.add(AltRep(locationAltrep)) } - props += loc + propSet += loc } if (geoLat != null && geoLong != null) { - props += Geo(geoLat!!.toBigDecimal(), geoLong!!.toBigDecimal()) + propSet += Geo(geoLat!!.toBigDecimal(), geoLong!!.toBigDecimal()) } geofenceRadius?.let { geofenceRadius -> - props += XProperty(X_PROP_GEOFENCE_RADIUS, geofenceRadius.toString()) + propSet += XProperty(X_PROP_GEOFENCE_RADIUS, geofenceRadius.toString()) } - color?.let { props += Color(null, Css3Color.nearestMatch(it).name) } + color?.let { propSet += Color(null, Css3Color.nearestMatch(it).name) } url?.let { try { - props += Url(URI(it)) + propSet += Url(URI(it)) } catch (e: URISyntaxException) { logger.log(Level.WARNING, "Ignoring invalid task URL: $url", e) } } - contact?.let { props += Contact(it) } + contact?.let { propSet += Contact(it) } - classification?.let { props += Clazz(it) } - status?.let { props += Status(it) } + classification?.let { propSet += Clazz(it) } + status?.let { propSet += Status(it) } xstatus?.let { xstatus -> - props += XProperty(X_PROP_XSTATUS, xstatus) + propSet += XProperty(X_PROP_XSTATUS, xstatus) } - val categoryTextList = TextList() - categories.forEach { - categoryTextList.add(it.text) + categories.map { it.text }.let { categoryTextList -> + if (categoryTextList.isNotEmpty()) + propSet += Categories(TextList(categoryTextList)) } - if (!categoryTextList.texts.isEmpty()) - props += Categories(categoryTextList) - val resourceTextList = TextList() - resources.forEach { - resourceTextList.add(it.text) + resources.map { it.text }.let { resourceTextList -> + if (resourceTextList.isNotEmpty()) + propSet += Resources(resourceTextList) } - if (!resourceTextList.texts.isEmpty()) - props += Resources(resourceTextList.texts.toList()) comments.forEach { comment -> val c = net.fortuna.ical4j.model.property.Comment(comment.text).apply { - comment.altrep?.let { this.parameterList.add(AltRep(it)) } - comment.language?.let { this.parameterList.add(Language(it)) } + comment.altrep?.let { this.add(AltRep(it)) } + comment.language?.let { this.add(Language(it)) } comment.other?.let { val xparams = JtxContract.getXParametersFromJson(it) xparams.forEach { xparam -> - this.parameterList.add(xparam) + this.add(xparam) } } } - props += c + propSet += c } @@ -837,109 +832,104 @@ open class JtxICalObject( this.calAddress = URI(attendee.caladdress) attendee.cn?.let { - this.parameterList.add(Cn(it)) + this.add(Cn(it)) } attendee.cutype?.let { - when { - it.equals(CuType.INDIVIDUAL.value, ignoreCase = true) -> this.parameterList.add(CuType.INDIVIDUAL) - it.equals(CuType.GROUP.value, ignoreCase = true) -> this.parameterList.add(CuType.GROUP) - it.equals(CuType.ROOM.value, ignoreCase = true) -> this.parameterList.add(CuType.ROOM) - it.equals(CuType.RESOURCE.value, ignoreCase = true) -> this.parameterList.add(CuType.RESOURCE) - it.equals(CuType.UNKNOWN.value, ignoreCase = true) -> this.parameterList.add(CuType.UNKNOWN) - else -> this.parameterList.add(CuType.UNKNOWN) - } + this.add(when { + it.equals(CuType.INDIVIDUAL.value, ignoreCase = true) -> CuType.INDIVIDUAL + it.equals(CuType.GROUP.value, ignoreCase = true) -> CuType.GROUP + it.equals(CuType.ROOM.value, ignoreCase = true) -> CuType.ROOM + it.equals(CuType.RESOURCE.value, ignoreCase = true) -> CuType.RESOURCE + it.equals(CuType.UNKNOWN.value, ignoreCase = true) -> CuType.UNKNOWN + else -> CuType.UNKNOWN + }) } attendee.delegatedfrom?.let { - this.parameterList.add(DelegatedFrom(it)) + this.add(DelegatedFrom(it)) } attendee.delegatedto?.let { - this.parameterList.add(DelegatedTo(it)) - } - attendee.dir?.let { - this.parameterList.add(Dir(it)) + this.add(DelegatedTo(it)) } + attendee.dir?.let { this.add(Dir(it)) } attendee.language?.let { - this.parameterList.add(Language(it)) + this.add(Language(it)) } attendee.member?.let { - this.parameterList.add(Member(it)) + this.add(Member(it)) } attendee.partstat?.let { - this.parameterList.add(PartStat(it)) + this.add(PartStat(it)) } attendee.role?.let { - this.parameterList.add(Role(it)) + this.add(Role(it)) } attendee.rsvp?.let { - this.parameterList.add(Rsvp(it)) + this.add(Rsvp(it)) } attendee.sentby?.let { - this.parameterList.add(SentBy(it)) + this.add(SentBy(it)) } attendee.other?.let { val params = JtxContract.getXParametersFromJson(it) params.forEach { xparam -> - this.parameterList.add(xparam) + this.add(xparam) } } } - props += attendeeProp + propSet += attendeeProp } organizer?.let { organizer -> val organizerProp = net.fortuna.ical4j.model.property.Organizer().apply { if(organizer.caladdress?.isNotEmpty() == true) - this.calAddress = URI(organizer.caladdress) + calAddress = URI(organizer.caladdress) organizer.cn?.let { - this.parameterList.add(Cn(it)) + add(Cn(it)) } organizer.dir?.let { - this.parameterList.add(Dir(it)) + add(Dir(it)) } organizer.language?.let { - this.parameterList.add(Language(it)) + add(Language(it)) } organizer.sentby?.let { - this.parameterList.add(SentBy(it)) + add(SentBy(it)) } organizer.other?.let { val params = JtxContract.getXParametersFromJson(it) params.forEach { xparam -> - this.parameterList.add(xparam) + add(xparam) } } } - props += organizerProp + propSet += organizerProp } attachments.forEach { attachment -> - try { if (attachment.uri?.startsWith("content://") == true) { - val attachmentUri = ContentUris.withAppendedId(JtxContract.JtxAttachment.CONTENT_URI.asSyncAdapter(collection.account), attachment.attachmentId) val attachmentFile = collection.client.openFile(attachmentUri, "r") val attachmentBytes = ByteBuffer.wrap(ParcelFileDescriptor.AutoCloseInputStream(attachmentFile).readBytes()) val att = Attach(attachmentBytes).apply { - attachment.fmttype?.let { this.parameterList.add(FmtType(it)) } + attachment.fmttype?.let { this.add(FmtType(it)) } attachment.filename?.let { - this.parameterList.add(XParameter(X_PARAM_ATTACH_LABEL, it)) - this.parameterList.add(XParameter(X_PARAM_FILENAME, it)) + this.add(XParameter(X_PARAM_ATTACH_LABEL, it)) + this.add(XParameter(X_PARAM_FILENAME, it)) } } - props += att - + propSet += att } else { attachment.uri?.let { uri -> val att = Attach(URI(uri)).apply { - attachment.fmttype?.let { this.parameterList.add(FmtType(it)) } + attachment.fmttype?.let { this.add(FmtType(it)) } attachment.filename?.let { - this.parameterList.add(XParameter(X_PARAM_ATTACH_LABEL, it)) - this.parameterList.add(XParameter(X_PARAM_FILENAME, it)) + this.add(XParameter(X_PARAM_ATTACH_LABEL, it)) + this.add(XParameter(X_PARAM_FILENAME, it)) } } - props += att + propSet += att } } } catch (e: FileNotFoundException) { @@ -953,7 +943,7 @@ open class JtxICalObject( unknown.forEach { it.value?.let { jsonString -> - props.add(UnknownProperty.fromJsonString(jsonString)) + propSet += UnknownProperty.fromJsonString(jsonString) } } @@ -965,104 +955,83 @@ open class JtxICalObject( RelType.PARENT.value -> RelType.PARENT else -> return@forEach } - val parameterList = ParameterList() - parameterList.add(param) - props += net.fortuna.ical4j.model.property.RelatedTo(parameterList, it.text) + val parameterList = ParameterList().add(param) + propSet += net.fortuna.ical4j.model.property.RelatedTo(parameterList, it.text) } dtstart?.let { - props += if (dtstartTimezone == TZ_ALLDAY || // allday uses UTC - dtstartTimezone.isNullOrEmpty() || // floating time -> use UTC to calculate instant - dtstartTimezone == ZoneOffset.UTC.id // UTC -> TZID=UTC - ) { - DtStart(Instant.ofEpochMilli(it)) - } else { - DtStart(ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.of(dtstartTimezone))) - } + val instant = Instant.ofEpochMilli(it) + propSet += DtStart(when { + dtstartTimezone == TZ_ALLDAY -> + instant.toLocalDate() + dtstartTimezone == ZoneOffset.UTC.id -> + instant.atZone(ZoneOffset.UTC) + dtstartTimezone.isNullOrEmpty() -> + instant.atZone(ZoneOffset.UTC).toLocalDateTime() + else -> + instant.atZone(ZoneId.of(dtstartTimezone)) + }) } rrule?.let { rrule -> - props += RRule(rrule) + propSet += RRule(rrule) } recurid?.let { recurid -> - props += if (recuridTimezone == TZ_ALLDAY || recuridTimezone.isNullOrEmpty()) + propSet += if (recuridTimezone == TZ_ALLDAY || recuridTimezone.isNullOrEmpty()) { RecurrenceId(recurid) - else + } else { RecurrenceId(ParameterList(listOf(TzId(recuridTimezone))), recurid) + } } rdate?.let { rdateString -> - - when { - dtstartTimezone == TZ_ALLDAY -> { - val localDates = DateList() - JtxContract.getLongListFromString(rdateString).forEach { - localDates.add(LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC)) - } - props += RDate(localDates) - } - dtstartTimezone == ZoneOffset.UTC.id -> { - val zonedDateTimes = DateList() - JtxContract.getLongListFromString(rdateString).forEach { - zonedDateTimes.add(ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC)) - } - props += RDate(zonedDateTimes) - } - dtstartTimezone.isNullOrEmpty() -> { - val localDateTimes = DateList() - JtxContract.getLongListFromString(rdateString).forEach { - localDateTimes.add(LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())) - } - props += RDate(localDateTimes) - } - else -> { - val zonedDateTimes = DateList() - JtxContract.getLongListFromString(rdateString).forEach { - zonedDateTimes.add(ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.of(dtstartTimezone))) - } - props += RDate(zonedDateTimes) - } - } + val rdates: MutableList = JtxContract.getLongListFromString(rdateString) + propSet += RDate(when { + dtstartTimezone == TZ_ALLDAY -> + DateList(rdates.map { + LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) + }) + dtstartTimezone == ZoneOffset.UTC.id -> + DateList(rdates.map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) + }) + dtstartTimezone.isNullOrEmpty() -> + DateList(rdates.map { + LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) + }) + else -> + DateList(rdates.map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.of(dtstartTimezone)) + }) + }) } exdate?.let { exdateString -> - - when { - dtstartTimezone == TZ_ALLDAY -> { - val localDates = DateList() - JtxContract.getLongListFromString(exdateString).forEach { - localDates.add(LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC)) - } - props += ExDate(localDates) - } - dtstartTimezone == ZoneOffset.UTC.id -> { - val zonedDateTimes = DateList() - JtxContract.getLongListFromString(exdateString).forEach { - zonedDateTimes.add(ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC)) - } - props += ExDate(zonedDateTimes) - } - dtstartTimezone.isNullOrEmpty() -> { - val localDateTimes = DateList() - JtxContract.getLongListFromString(exdateString).forEach { - localDateTimes.add(LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())) - } - props += ExDate(localDateTimes) - } - else -> { - val zonedDateTimes = DateList() - JtxContract.getLongListFromString(exdateString).forEach { - zonedDateTimes.add(ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.of(dtstartTimezone))) - } - props += ExDate(zonedDateTimes) - } - } + val exdates: MutableList = JtxContract.getLongListFromString(exdateString) + propSet += ExDate(when { + dtstartTimezone == TZ_ALLDAY -> + DateList(exdates.map { + LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) + }) + dtstartTimezone == ZoneOffset.UTC.id -> + DateList(exdates.map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) + }) + dtstartTimezone.isNullOrEmpty() -> + DateList(exdates.map { + LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault()) + }) + else -> + DateList(exdates.map { + ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.of(dtstartTimezone)) + }) + }) } duration?.let { val dur = Duration() dur.value = it - props += dur + propSet += dur } @@ -1076,37 +1045,45 @@ duration?.let(props::add) if(component == JtxContract.JtxICalObject.Component.VTODO.name) { completed?.let { // Completed is UNIX timestamp (milliseconds). But the X_PROP_COMPLETEDTIMEZONE can still define a timezone - props += Completed(Instant.ofEpochMilli(it)) + propSet += Completed(Instant.ofEpochMilli(it)) // only take completedTimezone if completed time is set completedTimezone?.let { complTZ -> - props += XProperty(X_PROP_COMPLETEDTIMEZONE, complTZ) + propSet += XProperty(X_PROP_COMPLETEDTIMEZONE, complTZ) } } percent?.let { - props += PercentComplete(it) + propSet += PercentComplete(it) } if (priority != null && priority != ImmutablePriority.UNDEFINED.level) priority?.let { - props += Priority(it) + propSet += Priority(it) } else { - props += Priority(ImmutablePriority.UNDEFINED.level) + propSet += Priority(ImmutablePriority.UNDEFINED.level) } due?.let { - props += when { - dueTimezone == TZ_ALLDAY -> Due(LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC)) - dueTimezone == ZoneOffset.UTC.id -> Due(ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC)) - dueTimezone.isNullOrEmpty() -> Due(LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.systemDefault())) - else -> Due(ZonedDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneId.of(dueTimezone))) - } + val instant = Instant.ofEpochMilli(it) + propSet += Due(when { + dtstartTimezone == TZ_ALLDAY -> + instant.toLocalDate() + dtstartTimezone == ZoneOffset.UTC.id -> + instant.atZone(ZoneOffset.UTC) + dtstartTimezone.isNullOrEmpty() -> + instant.atZone(ZoneOffset.UTC).toLocalDateTime() + else -> + instant.atZone(ZoneId.of(dtstartTimezone)) + }) } } - } + + // Add properties to PropertyList + return props.addAll(propSet) + } /* // determine earliest referenced date From 24c8cf432e872a96de3b313fab07e3c22d3e6413 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 26 Mar 2026 13:10:16 +0100 Subject: [PATCH 2/6] Minor changes and comments --- .../at/bitfire/ical4android/JtxICalObject.kt | 59 ++++++++++--------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt index 40019a2a..90028d1d 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt @@ -434,7 +434,7 @@ open class JtxICalObject( is DtStart<*> -> { val temporal = prop.normalizedDate() iCalObject.dtstart = temporal.toEpochMilli() - iCalObject.dtstartTimezone = prop.normalizedDate().getTimeZoneId() + iCalObject.dtstartTimezone = temporal.getTimeZoneId() } is PercentComplete -> { @@ -665,7 +665,7 @@ open class JtxICalObject( JtxContract.JtxICalObject.Component.VJOURNAL.name -> VJournal(true /* generates DTSTAMP */) else -> return null } - calComponent.propertyList = addProperties(calComponent.propertyList) + calComponent.propertyList = addProperties(calComponent.propertyList) // Need to re-set the immutable list ical += calComponent alarms.forEach { alarm -> @@ -689,9 +689,9 @@ open class JtxICalObject( // Add the RELATED parameter if present alarm.triggerRelativeTo?.let { if(it == JtxContract.JtxAlarm.AlarmRelativeTo.START.name) - this.add(Related.START) + this += Related.START if(it == JtxContract.JtxAlarm.AlarmRelativeTo.END.name) - this.add(Related.END) + this += Related.END } } catch (e: DateTimeParseException) { logger.log(Level.WARNING, "Could not parse Trigger duration as Duration.", e) @@ -759,9 +759,10 @@ open class JtxICalObject( /** * This function maps the current JtxICalObject to a iCalendar property list * @param [props] The PropertyList where the properties should be added + * @return The PropertyList with the added properties */ - private fun addProperties(props: PropertyList): PropertyList { - val propSet = mutableSetOf() + private fun addProperties(props: PropertyList): PropertyList? { + val propSet = mutableListOf() uid.let { propSet += Uid(it) } sequence.let { propSet += Sequence(it.toInt()) } @@ -814,12 +815,12 @@ open class JtxICalObject( comments.forEach { comment -> val c = net.fortuna.ical4j.model.property.Comment(comment.text).apply { - comment.altrep?.let { this.add(AltRep(it)) } - comment.language?.let { this.add(Language(it)) } + comment.altrep?.let { this += AltRep(it) } + comment.language?.let { this += Language(it) } comment.other?.let { val xparams = JtxContract.getXParametersFromJson(it) xparams.forEach { xparam -> - this.add(xparam) + this += xparam } } } @@ -832,47 +833,47 @@ open class JtxICalObject( this.calAddress = URI(attendee.caladdress) attendee.cn?.let { - this.add(Cn(it)) + this += Cn(it) } attendee.cutype?.let { - this.add(when { + this += when { it.equals(CuType.INDIVIDUAL.value, ignoreCase = true) -> CuType.INDIVIDUAL it.equals(CuType.GROUP.value, ignoreCase = true) -> CuType.GROUP it.equals(CuType.ROOM.value, ignoreCase = true) -> CuType.ROOM it.equals(CuType.RESOURCE.value, ignoreCase = true) -> CuType.RESOURCE it.equals(CuType.UNKNOWN.value, ignoreCase = true) -> CuType.UNKNOWN else -> CuType.UNKNOWN - }) + } } attendee.delegatedfrom?.let { - this.add(DelegatedFrom(it)) + this += DelegatedFrom(it) } attendee.delegatedto?.let { - this.add(DelegatedTo(it)) + this += DelegatedTo(it) } - attendee.dir?.let { this.add(Dir(it)) } + attendee.dir?.let { this += Dir(it) } attendee.language?.let { - this.add(Language(it)) + this += Language(it) } attendee.member?.let { - this.add(Member(it)) + this += Member(it) } attendee.partstat?.let { - this.add(PartStat(it)) + this += PartStat(it) } attendee.role?.let { - this.add(Role(it)) + this += Role(it) } attendee.rsvp?.let { - this.add(Rsvp(it)) + this += Rsvp(it) } attendee.sentby?.let { - this.add(SentBy(it)) + this += SentBy(it) } attendee.other?.let { val params = JtxContract.getXParametersFromJson(it) params.forEach { xparam -> - this.add(xparam) + this += xparam } } } @@ -913,20 +914,20 @@ open class JtxICalObject( val attachmentFile = collection.client.openFile(attachmentUri, "r") val attachmentBytes = ByteBuffer.wrap(ParcelFileDescriptor.AutoCloseInputStream(attachmentFile).readBytes()) val att = Attach(attachmentBytes).apply { - attachment.fmttype?.let { this.add(FmtType(it)) } + attachment.fmttype?.let { this += FmtType(it) } attachment.filename?.let { - this.add(XParameter(X_PARAM_ATTACH_LABEL, it)) - this.add(XParameter(X_PARAM_FILENAME, it)) + this += XParameter(X_PARAM_ATTACH_LABEL, it) + this += XParameter(X_PARAM_FILENAME, it) } } propSet += att } else { attachment.uri?.let { uri -> val att = Attach(URI(uri)).apply { - attachment.fmttype?.let { this.add(FmtType(it)) } + attachment.fmttype?.let { this += FmtType(it) } attachment.filename?.let { - this.add(XParameter(X_PARAM_ATTACH_LABEL, it)) - this.add(XParameter(X_PARAM_FILENAME, it)) + this += XParameter(X_PARAM_ATTACH_LABEL, it) + this += XParameter(X_PARAM_FILENAME, it) } } propSet += att @@ -1082,7 +1083,7 @@ duration?.let(props::add) } // Add properties to PropertyList - return props.addAll(propSet) + return props.addAll(propSet) // the list is immutable and "addAll" returns a copy which we need to return } /* From ca2d8c16450660114aadd5a7dbfe59166648cd20 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 26 Mar 2026 13:52:28 +0100 Subject: [PATCH 3/6] Fix helpers after rebase --- .../at/bitfire/ical4android/JtxICalObject.kt | 4 +-- .../ical4android/util/TimeApiExtensions.kt | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt index 90028d1d..f82b73a7 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt @@ -13,8 +13,8 @@ import android.os.ParcelFileDescriptor import android.util.Base64 import androidx.core.content.contentValuesOf import at.bitfire.ical4android.ICalendar.Companion.withUserAgents -import at.bitfire.ical4android.util.DateUtils.toEpochMilli -import at.bitfire.ical4android.util.DateUtils.toLocalDate +import at.bitfire.ical4android.util.TimeApiExtensions.toEpochMilli +import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate import at.bitfire.synctools.exception.InvalidICalendarException import at.bitfire.synctools.icalendar.Css3Color import at.bitfire.synctools.icalendar.DatePropertyTzMapper.normalizedDate diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt b/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt index 8c9c0dff..20f05efd 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt @@ -16,6 +16,8 @@ import java.time.Period import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime +import java.time.temporal.ChronoField +import java.time.temporal.ChronoField.INSTANT_SECONDS import java.time.temporal.Temporal import java.time.temporal.TemporalAmount import java.util.Calendar @@ -144,6 +146,32 @@ object TimeApiExtensions { /***** Temporals *****/ + /** + * Converts the given generic [Temporal] into milliseconds since epoch. + * @param fallbackTimezone Any specific timezone to use as fallback if there's not enough + * information on the [Temporal] type (local types). Defaults to UTC. + * @throws IllegalArgumentException if the [Temporal] is from an unknown time, which also doesn't + * support [ChronoField.INSTANT_SECONDS] + */ + fun Temporal.toEpochMilli(fallbackTimezone: ZoneId? = null): Long { + // If the temporal supports instant seconds, we can compute epoch millis directly from them + if (isSupported(INSTANT_SECONDS)) { + val seconds = getLong(ChronoField.INSTANT_SECONDS) + val nanos = get(ChronoField.NANO_OF_SECOND) + // Convert seconds and nanos to millis + return (seconds * 1000) + (nanos / 1_000_000) + } + + return when (this) { + is Instant -> this.toEpochMilli() + is ZonedDateTime -> this.toInstant().toEpochMilli() + is OffsetDateTime -> this.toInstant().toEpochMilli() + is LocalDate -> this.atStartOfDay(fallbackTimezone ?: ZoneOffset.UTC).toInstant().toEpochMilli() + is LocalDateTime -> this.atZone(fallbackTimezone ?: ZoneOffset.UTC).toInstant().toEpochMilli() + else -> error("${this::class.java.simpleName} cannot be converted to epoch millis.") + } + } + /** * Gets the [LocalDate] part of this [Temporal] instance. */ From afead7f497381c96d2b11f87b7633078b90b672e Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 26 Mar 2026 14:16:23 +0100 Subject: [PATCH 4/6] Rename variable --- .../at/bitfire/ical4android/JtxICalObject.kt | 78 +++++++++---------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt index f82b73a7..df9bf618 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt @@ -759,57 +759,57 @@ open class JtxICalObject( /** * This function maps the current JtxICalObject to a iCalendar property list * @param [props] The PropertyList where the properties should be added - * @return The PropertyList with the added properties + * @return A copy of given PropertyList with the added properties */ private fun addProperties(props: PropertyList): PropertyList? { - val propSet = mutableListOf() - uid.let { propSet += Uid(it) } - sequence.let { propSet += Sequence(it.toInt()) } + val properties = mutableListOf() + uid.let { properties += Uid(it) } + sequence.let { properties += Sequence(it.toInt()) } - created.let { propSet += Created(Instant.ofEpochMilli(it)) } - lastModified.let { propSet += LastModified(Instant.ofEpochMilli(it))} + created.let { properties += Created(Instant.ofEpochMilli(it)) } + lastModified.let { properties += LastModified(Instant.ofEpochMilli(it))} - summary.let { propSet += Summary(it) } - description?.let { propSet += Description(it) } + summary.let { properties += Summary(it) } + description?.let { properties += Description(it) } location?.let { location -> val loc = Location(location) locationAltrep?.let { locationAltrep -> loc.add(AltRep(locationAltrep)) } - propSet += loc + properties += loc } if (geoLat != null && geoLong != null) { - propSet += Geo(geoLat!!.toBigDecimal(), geoLong!!.toBigDecimal()) + properties += Geo(geoLat!!.toBigDecimal(), geoLong!!.toBigDecimal()) } geofenceRadius?.let { geofenceRadius -> - propSet += XProperty(X_PROP_GEOFENCE_RADIUS, geofenceRadius.toString()) + properties += XProperty(X_PROP_GEOFENCE_RADIUS, geofenceRadius.toString()) } - color?.let { propSet += Color(null, Css3Color.nearestMatch(it).name) } + color?.let { properties += Color(null, Css3Color.nearestMatch(it).name) } url?.let { try { - propSet += Url(URI(it)) + properties += Url(URI(it)) } catch (e: URISyntaxException) { logger.log(Level.WARNING, "Ignoring invalid task URL: $url", e) } } - contact?.let { propSet += Contact(it) } + contact?.let { properties += Contact(it) } - classification?.let { propSet += Clazz(it) } - status?.let { propSet += Status(it) } + classification?.let { properties += Clazz(it) } + status?.let { properties += Status(it) } xstatus?.let { xstatus -> - propSet += XProperty(X_PROP_XSTATUS, xstatus) + properties += XProperty(X_PROP_XSTATUS, xstatus) } categories.map { it.text }.let { categoryTextList -> if (categoryTextList.isNotEmpty()) - propSet += Categories(TextList(categoryTextList)) + properties += Categories(TextList(categoryTextList)) } resources.map { it.text }.let { resourceTextList -> if (resourceTextList.isNotEmpty()) - propSet += Resources(resourceTextList) + properties += Resources(resourceTextList) } @@ -824,7 +824,7 @@ open class JtxICalObject( } } } - propSet += c + properties += c } @@ -877,7 +877,7 @@ open class JtxICalObject( } } } - propSet += attendeeProp + properties += attendeeProp } organizer?.let { organizer -> @@ -904,7 +904,7 @@ open class JtxICalObject( } } } - propSet += organizerProp + properties += organizerProp } attachments.forEach { attachment -> @@ -920,7 +920,7 @@ open class JtxICalObject( this += XParameter(X_PARAM_FILENAME, it) } } - propSet += att + properties += att } else { attachment.uri?.let { uri -> val att = Attach(URI(uri)).apply { @@ -930,7 +930,7 @@ open class JtxICalObject( this += XParameter(X_PARAM_FILENAME, it) } } - propSet += att + properties += att } } } catch (e: FileNotFoundException) { @@ -944,7 +944,7 @@ open class JtxICalObject( unknown.forEach { it.value?.let { jsonString -> - propSet += UnknownProperty.fromJsonString(jsonString) + properties += UnknownProperty.fromJsonString(jsonString) } } @@ -957,12 +957,12 @@ open class JtxICalObject( else -> return@forEach } val parameterList = ParameterList().add(param) - propSet += net.fortuna.ical4j.model.property.RelatedTo(parameterList, it.text) + properties += net.fortuna.ical4j.model.property.RelatedTo(parameterList, it.text) } dtstart?.let { val instant = Instant.ofEpochMilli(it) - propSet += DtStart(when { + properties += DtStart(when { dtstartTimezone == TZ_ALLDAY -> instant.toLocalDate() dtstartTimezone == ZoneOffset.UTC.id -> @@ -975,10 +975,10 @@ open class JtxICalObject( } rrule?.let { rrule -> - propSet += RRule(rrule) + properties += RRule(rrule) } recurid?.let { recurid -> - propSet += if (recuridTimezone == TZ_ALLDAY || recuridTimezone.isNullOrEmpty()) { + properties += if (recuridTimezone == TZ_ALLDAY || recuridTimezone.isNullOrEmpty()) { RecurrenceId(recurid) } else { RecurrenceId(ParameterList(listOf(TzId(recuridTimezone))), recurid) @@ -987,7 +987,7 @@ open class JtxICalObject( rdate?.let { rdateString -> val rdates: MutableList = JtxContract.getLongListFromString(rdateString) - propSet += RDate(when { + properties += RDate(when { dtstartTimezone == TZ_ALLDAY -> DateList(rdates.map { LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) @@ -1009,7 +1009,7 @@ open class JtxICalObject( exdate?.let { exdateString -> val exdates: MutableList = JtxContract.getLongListFromString(exdateString) - propSet += ExDate(when { + properties += ExDate(when { dtstartTimezone == TZ_ALLDAY -> DateList(exdates.map { LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) @@ -1032,7 +1032,7 @@ open class JtxICalObject( duration?.let { val dur = Duration() dur.value = it - propSet += dur + properties += dur } @@ -1046,30 +1046,30 @@ duration?.let(props::add) if(component == JtxContract.JtxICalObject.Component.VTODO.name) { completed?.let { // Completed is UNIX timestamp (milliseconds). But the X_PROP_COMPLETEDTIMEZONE can still define a timezone - propSet += Completed(Instant.ofEpochMilli(it)) + properties += Completed(Instant.ofEpochMilli(it)) // only take completedTimezone if completed time is set completedTimezone?.let { complTZ -> - propSet += XProperty(X_PROP_COMPLETEDTIMEZONE, complTZ) + properties += XProperty(X_PROP_COMPLETEDTIMEZONE, complTZ) } } percent?.let { - propSet += PercentComplete(it) + properties += PercentComplete(it) } if (priority != null && priority != ImmutablePriority.UNDEFINED.level) priority?.let { - propSet += Priority(it) + properties += Priority(it) } else { - propSet += Priority(ImmutablePriority.UNDEFINED.level) + properties += Priority(ImmutablePriority.UNDEFINED.level) } due?.let { val instant = Instant.ofEpochMilli(it) - propSet += Due(when { + properties += Due(when { dtstartTimezone == TZ_ALLDAY -> instant.toLocalDate() dtstartTimezone == ZoneOffset.UTC.id -> @@ -1083,7 +1083,7 @@ duration?.let(props::add) } // Add properties to PropertyList - return props.addAll(propSet) // the list is immutable and "addAll" returns a copy which we need to return + return props.addAll(properties) // the list is immutable and "addAll" returns a copy which we need to return } /* From 3888395a18e979f346c6c19360dc4c15e08a2305 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 26 Mar 2026 14:27:47 +0100 Subject: [PATCH 5/6] Use Temporal.toTimestamp() from AndroidTimeUtils and remove Temporal.toEpochMilli again --- .../at/bitfire/ical4android/JtxICalObject.kt | 22 +++++++------- .../ical4android/util/TimeApiExtensions.kt | 29 ------------------- 2 files changed, 11 insertions(+), 40 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt index df9bf618..3eba66e2 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt @@ -13,7 +13,6 @@ import android.os.ParcelFileDescriptor import android.util.Base64 import androidx.core.content.contentValuesOf import at.bitfire.ical4android.ICalendar.Companion.withUserAgents -import at.bitfire.ical4android.util.TimeApiExtensions.toEpochMilli import at.bitfire.ical4android.util.TimeApiExtensions.toLocalDate import at.bitfire.synctools.exception.InvalidICalendarException import at.bitfire.synctools.icalendar.Css3Color @@ -24,6 +23,7 @@ import at.bitfire.synctools.icalendar.plusAssign import at.bitfire.synctools.storage.BatchOperation import at.bitfire.synctools.storage.JtxBatchOperation import at.bitfire.synctools.storage.toContentValues +import at.bitfire.synctools.util.AndroidTimeUtils.toTimestamp import at.techbee.jtx.JtxContract import at.techbee.jtx.JtxContract.JtxICalObject.TZ_ALLDAY import at.techbee.jtx.JtxContract.asSyncAdapter @@ -358,7 +358,7 @@ open class JtxICalObject( trigger.getParameter(Parameter.RELATED)?.getOrNull()?.let { related -> this.triggerRelativeTo = related.value } } else if(trigger.isAbsolute) { // self-contained (not relative to dtstart/dtend) val normalizedTrigger = trigger.normalizedDate() // Ensure timezone exists in system - this.triggerTime = normalizedTrigger.toEpochMilli() + this.triggerTime = normalizedTrigger.toTimestamp() this.triggerTimezone = normalizedTrigger.getTimeZoneId() } } @@ -392,8 +392,8 @@ open class JtxICalObject( for (prop in properties.all) { when (prop) { is Sequence -> iCalObject.sequence = prop.sequenceNo.toLong() - is Created -> iCalObject.created = prop.normalizedDate().toEpochMilli() - is LastModified -> iCalObject.lastModified = prop.normalizedDate().toEpochMilli() + is Created -> iCalObject.created = prop.normalizedDate().toTimestamp() + is LastModified -> iCalObject.lastModified = prop.normalizedDate().toTimestamp() is Summary -> iCalObject.summary = prop.value is Location -> { iCalObject.location = prop.value @@ -417,7 +417,7 @@ open class JtxICalObject( logger.warning("The property Completed is only supported for VTODO, this value is rejected.") continue } - iCalObject.completed = prop.normalizedDate().toEpochMilli() + iCalObject.completed = prop.normalizedDate().toTimestamp() } is Due<*> -> { @@ -425,7 +425,7 @@ open class JtxICalObject( logger.warning("The property Due is only supported for VTODO, this value is rejected.") continue } - iCalObject.due = prop.normalizedDate().toEpochMilli() + iCalObject.due = prop.normalizedDate().toTimestamp() iCalObject.dueTimezone = prop.normalizedDate().getTimeZoneId() } @@ -433,7 +433,7 @@ open class JtxICalObject( is DtStart<*> -> { val temporal = prop.normalizedDate() - iCalObject.dtstart = temporal.toEpochMilli() + iCalObject.dtstart = temporal.toTimestamp() iCalObject.dtstartTimezone = temporal.getTimeZoneId() } @@ -450,7 +450,7 @@ open class JtxICalObject( if(!iCalObject.rdate.isNullOrEmpty()) add(JtxContract.getLongListFromString(iCalObject.rdate!!)) prop.normalizedDates().forEach { date -> - add(date.toEpochMilli()) + add(date.toTimestamp()) } }.toTypedArray().joinToString(separator = ",") } @@ -459,7 +459,7 @@ open class JtxICalObject( if(!iCalObject.exdate.isNullOrEmpty()) add(JtxContract.getLongListFromString(iCalObject.exdate!!)) prop.normalizedDates().forEach { date -> - add(date.toEpochMilli()) + add(date.toTimestamp()) } }.toTypedArray().joinToString(separator = ",") } @@ -968,7 +968,7 @@ open class JtxICalObject( dtstartTimezone == ZoneOffset.UTC.id -> instant.atZone(ZoneOffset.UTC) dtstartTimezone.isNullOrEmpty() -> - instant.atZone(ZoneOffset.UTC).toLocalDateTime() + instant.atZone(ZoneId.systemDefault()).toLocalDateTime() else -> instant.atZone(ZoneId.of(dtstartTimezone)) }) @@ -1075,7 +1075,7 @@ duration?.let(props::add) dtstartTimezone == ZoneOffset.UTC.id -> instant.atZone(ZoneOffset.UTC) dtstartTimezone.isNullOrEmpty() -> - instant.atZone(ZoneOffset.UTC).toLocalDateTime() + instant.atZone(ZoneId.systemDefault()).toLocalDateTime() else -> instant.atZone(ZoneId.of(dtstartTimezone)) }) diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt b/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt index 20f05efd..d9a7a2f9 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/util/TimeApiExtensions.kt @@ -13,11 +13,8 @@ import java.time.LocalDate import java.time.LocalDateTime import java.time.OffsetDateTime import java.time.Period -import java.time.ZoneId import java.time.ZoneOffset import java.time.ZonedDateTime -import java.time.temporal.ChronoField -import java.time.temporal.ChronoField.INSTANT_SECONDS import java.time.temporal.Temporal import java.time.temporal.TemporalAmount import java.util.Calendar @@ -146,32 +143,6 @@ object TimeApiExtensions { /***** Temporals *****/ - /** - * Converts the given generic [Temporal] into milliseconds since epoch. - * @param fallbackTimezone Any specific timezone to use as fallback if there's not enough - * information on the [Temporal] type (local types). Defaults to UTC. - * @throws IllegalArgumentException if the [Temporal] is from an unknown time, which also doesn't - * support [ChronoField.INSTANT_SECONDS] - */ - fun Temporal.toEpochMilli(fallbackTimezone: ZoneId? = null): Long { - // If the temporal supports instant seconds, we can compute epoch millis directly from them - if (isSupported(INSTANT_SECONDS)) { - val seconds = getLong(ChronoField.INSTANT_SECONDS) - val nanos = get(ChronoField.NANO_OF_SECOND) - // Convert seconds and nanos to millis - return (seconds * 1000) + (nanos / 1_000_000) - } - - return when (this) { - is Instant -> this.toEpochMilli() - is ZonedDateTime -> this.toInstant().toEpochMilli() - is OffsetDateTime -> this.toInstant().toEpochMilli() - is LocalDate -> this.atStartOfDay(fallbackTimezone ?: ZoneOffset.UTC).toInstant().toEpochMilli() - is LocalDateTime -> this.atZone(fallbackTimezone ?: ZoneOffset.UTC).toInstant().toEpochMilli() - else -> error("${this::class.java.simpleName} cannot be converted to epoch millis.") - } - } - /** * Gets the [LocalDate] part of this [Temporal] instance. */ From 0f1b77e292bb755ac737202a044ae48f60317dca Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Fri, 27 Mar 2026 12:59:09 +0100 Subject: [PATCH 6/6] Minor changes --- .../at/bitfire/ical4android/JtxICalObject.kt | 84 +++++++++---------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt index 3eba66e2..d85282af 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/JtxICalObject.kt @@ -448,7 +448,7 @@ open class JtxICalObject( is RDate<*> -> { iCalObject.rdate = buildList { if(!iCalObject.rdate.isNullOrEmpty()) - add(JtxContract.getLongListFromString(iCalObject.rdate!!)) + addAll(JtxContract.getLongListFromString(iCalObject.rdate!!)) prop.normalizedDates().forEach { date -> add(date.toTimestamp()) } @@ -457,7 +457,7 @@ open class JtxICalObject( is ExDate<*> -> { iCalObject.exdate = buildList { if(!iCalObject.exdate.isNullOrEmpty()) - add(JtxContract.getLongListFromString(iCalObject.exdate!!)) + addAll(JtxContract.getLongListFromString(iCalObject.exdate!!)) prop.normalizedDates().forEach { date -> add(date.toTimestamp()) } @@ -758,58 +758,58 @@ open class JtxICalObject( /** * This function maps the current JtxICalObject to a iCalendar property list - * @param [props] The PropertyList where the properties should be added + * @param [propertyList] The PropertyList where the properties should be added * @return A copy of given PropertyList with the added properties */ - private fun addProperties(props: PropertyList): PropertyList? { - val properties = mutableListOf() - uid.let { properties += Uid(it) } - sequence.let { properties += Sequence(it.toInt()) } + private fun addProperties(propertyList: PropertyList): PropertyList? { + val props = mutableListOf() + uid.let { props += Uid(it) } + sequence.let { props += Sequence(it.toInt()) } - created.let { properties += Created(Instant.ofEpochMilli(it)) } - lastModified.let { properties += LastModified(Instant.ofEpochMilli(it))} + created.let { props += Created(Instant.ofEpochMilli(it)) } + lastModified.let { props += LastModified(Instant.ofEpochMilli(it))} - summary.let { properties += Summary(it) } - description?.let { properties += Description(it) } + summary.let { props += Summary(it) } + description?.let { props += Description(it) } location?.let { location -> val loc = Location(location) locationAltrep?.let { locationAltrep -> loc.add(AltRep(locationAltrep)) } - properties += loc + props += loc } if (geoLat != null && geoLong != null) { - properties += Geo(geoLat!!.toBigDecimal(), geoLong!!.toBigDecimal()) + props += Geo(geoLat!!.toBigDecimal(), geoLong!!.toBigDecimal()) } geofenceRadius?.let { geofenceRadius -> - properties += XProperty(X_PROP_GEOFENCE_RADIUS, geofenceRadius.toString()) + props += XProperty(X_PROP_GEOFENCE_RADIUS, geofenceRadius.toString()) } - color?.let { properties += Color(null, Css3Color.nearestMatch(it).name) } + color?.let { props += Color(null, Css3Color.nearestMatch(it).name) } url?.let { try { - properties += Url(URI(it)) + props += Url(URI(it)) } catch (e: URISyntaxException) { logger.log(Level.WARNING, "Ignoring invalid task URL: $url", e) } } - contact?.let { properties += Contact(it) } + contact?.let { props += Contact(it) } - classification?.let { properties += Clazz(it) } - status?.let { properties += Status(it) } + classification?.let { props += Clazz(it) } + status?.let { props += Status(it) } xstatus?.let { xstatus -> - properties += XProperty(X_PROP_XSTATUS, xstatus) + props += XProperty(X_PROP_XSTATUS, xstatus) } categories.map { it.text }.let { categoryTextList -> if (categoryTextList.isNotEmpty()) - properties += Categories(TextList(categoryTextList)) + props += Categories(TextList(categoryTextList)) } resources.map { it.text }.let { resourceTextList -> if (resourceTextList.isNotEmpty()) - properties += Resources(resourceTextList) + props += Resources(resourceTextList) } @@ -824,7 +824,7 @@ open class JtxICalObject( } } } - properties += c + props += c } @@ -877,7 +877,7 @@ open class JtxICalObject( } } } - properties += attendeeProp + props += attendeeProp } organizer?.let { organizer -> @@ -904,7 +904,7 @@ open class JtxICalObject( } } } - properties += organizerProp + props += organizerProp } attachments.forEach { attachment -> @@ -920,7 +920,7 @@ open class JtxICalObject( this += XParameter(X_PARAM_FILENAME, it) } } - properties += att + props += att } else { attachment.uri?.let { uri -> val att = Attach(URI(uri)).apply { @@ -930,7 +930,7 @@ open class JtxICalObject( this += XParameter(X_PARAM_FILENAME, it) } } - properties += att + props += att } } } catch (e: FileNotFoundException) { @@ -944,7 +944,7 @@ open class JtxICalObject( unknown.forEach { it.value?.let { jsonString -> - properties += UnknownProperty.fromJsonString(jsonString) + props += UnknownProperty.fromJsonString(jsonString) } } @@ -957,12 +957,12 @@ open class JtxICalObject( else -> return@forEach } val parameterList = ParameterList().add(param) - properties += net.fortuna.ical4j.model.property.RelatedTo(parameterList, it.text) + props += net.fortuna.ical4j.model.property.RelatedTo(parameterList, it.text) } dtstart?.let { val instant = Instant.ofEpochMilli(it) - properties += DtStart(when { + props += DtStart(when { dtstartTimezone == TZ_ALLDAY -> instant.toLocalDate() dtstartTimezone == ZoneOffset.UTC.id -> @@ -975,10 +975,10 @@ open class JtxICalObject( } rrule?.let { rrule -> - properties += RRule(rrule) + props += RRule(rrule) } recurid?.let { recurid -> - properties += if (recuridTimezone == TZ_ALLDAY || recuridTimezone.isNullOrEmpty()) { + props += if (recuridTimezone == TZ_ALLDAY || recuridTimezone.isNullOrEmpty()) { RecurrenceId(recurid) } else { RecurrenceId(ParameterList(listOf(TzId(recuridTimezone))), recurid) @@ -987,7 +987,7 @@ open class JtxICalObject( rdate?.let { rdateString -> val rdates: MutableList = JtxContract.getLongListFromString(rdateString) - properties += RDate(when { + props += RDate(when { dtstartTimezone == TZ_ALLDAY -> DateList(rdates.map { LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) @@ -1009,7 +1009,7 @@ open class JtxICalObject( exdate?.let { exdateString -> val exdates: MutableList = JtxContract.getLongListFromString(exdateString) - properties += ExDate(when { + props += ExDate(when { dtstartTimezone == TZ_ALLDAY -> DateList(exdates.map { LocalDate.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) @@ -1032,7 +1032,7 @@ open class JtxICalObject( duration?.let { val dur = Duration() dur.value = it - properties += dur + props += dur } @@ -1046,30 +1046,30 @@ duration?.let(props::add) if(component == JtxContract.JtxICalObject.Component.VTODO.name) { completed?.let { // Completed is UNIX timestamp (milliseconds). But the X_PROP_COMPLETEDTIMEZONE can still define a timezone - properties += Completed(Instant.ofEpochMilli(it)) + props += Completed(Instant.ofEpochMilli(it)) // only take completedTimezone if completed time is set completedTimezone?.let { complTZ -> - properties += XProperty(X_PROP_COMPLETEDTIMEZONE, complTZ) + props += XProperty(X_PROP_COMPLETEDTIMEZONE, complTZ) } } percent?.let { - properties += PercentComplete(it) + props += PercentComplete(it) } if (priority != null && priority != ImmutablePriority.UNDEFINED.level) priority?.let { - properties += Priority(it) + props += Priority(it) } else { - properties += Priority(ImmutablePriority.UNDEFINED.level) + props += Priority(ImmutablePriority.UNDEFINED.level) } due?.let { val instant = Instant.ofEpochMilli(it) - properties += Due(when { + props += Due(when { dtstartTimezone == TZ_ALLDAY -> instant.toLocalDate() dtstartTimezone == ZoneOffset.UTC.id -> @@ -1083,7 +1083,7 @@ duration?.let(props::add) } // Add properties to PropertyList - return props.addAll(properties) // the list is immutable and "addAll" returns a copy which we need to return + return propertyList.addAll(props) // the list is immutable and "addAll" returns a copy which we need to return } /*