From 20ae9be34d1eb4b99f69f5413b319ee867a5ebe2 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 29 Jan 2026 14:35:21 +0100 Subject: [PATCH 1/3] Extract task reading/parsing from data class --- .../at/bitfire/ical4android/ICalendar.kt | 4 +- .../kotlin/at/bitfire/ical4android/Task.kt | 103 +-------- .../at/bitfire/ical4android/TaskReader.kt | 148 ++++++++++++ .../at/bitfire/ical4android/TaskReaderTest.kt | 217 ++++++++++++++++++ .../at/bitfire/ical4android/TaskTest.kt | 193 ---------------- 5 files changed, 373 insertions(+), 292 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt create mode 100644 lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt b/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt index 25736df7..36fdfbb7 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/ICalendar.kt @@ -287,7 +287,7 @@ open class ICalendar { val trigger = alarm.trigger ?: return null val minutes: Int // minutes before/after the event - var related = trigger.getParameter(Parameter.RELATED) ?: Related.START + var related = trigger.getParameter(Parameter.RELATED) ?: Related.START // event/task start time val start: java.util.Date? = refStart?.date @@ -351,7 +351,7 @@ open class ICalendar { } - protected fun generateUID() { + fun generateUID() { uid = UUID.randomUUID().toString() } diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt index 978edfdd..5183265c 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt @@ -8,11 +8,9 @@ package at.bitfire.ical4android import androidx.annotation.IntRange import at.bitfire.ical4android.util.DateUtils -import at.bitfire.synctools.exception.InvalidICalendarException import at.bitfire.synctools.icalendar.Css3Color import net.fortuna.ical4j.data.CalendarOutputter import net.fortuna.ical4j.model.Calendar -import net.fortuna.ical4j.model.Component import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.Property import net.fortuna.ical4j.model.TextList @@ -26,7 +24,6 @@ import net.fortuna.ical4j.model.property.Comment import net.fortuna.ical4j.model.property.Completed import net.fortuna.ical4j.model.property.Created import net.fortuna.ical4j.model.property.Description -import net.fortuna.ical4j.model.property.DtStamp import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Due import net.fortuna.ical4j.model.property.Duration @@ -47,15 +44,19 @@ import net.fortuna.ical4j.model.property.Summary import net.fortuna.ical4j.model.property.Uid import net.fortuna.ical4j.model.property.Url import net.fortuna.ical4j.model.property.Version -import java.io.IOException import java.io.OutputStream -import java.io.Reader import java.net.URI import java.net.URISyntaxException import java.util.LinkedList import java.util.logging.Level import java.util.logging.Logger +/** + * Data class representing a task + * + * - as it is extracted from an iCalendar or + * - as it should be generated into an iCalendar. + */ data class Task( var createdAt: Long? = null, var lastModified: Long? = null, @@ -99,98 +100,6 @@ data class Task( private val logger get() = Logger.getLogger(Task::class.java.name) - /** - * Parses an iCalendar resource, applies [at.bitfire.synctools.icalendar.validation.ICalPreprocessor] to increase compatibility - * and extracts the VTODOs. - * - * @param reader where the iCalendar is taken from - * - * @return array of filled [Task] data objects (may have size 0) - * - * @throws InvalidICalendarException when the iCalendar can't be parsed - * @throws IOException on I/O errors - */ - fun tasksFromReader(reader: Reader): List { - val ical = fromReader(reader) - val vToDos = ical.getComponents(Component.VTODO) - return vToDos.mapTo(LinkedList()) { this.fromVToDo(it) } - } - - private fun fromVToDo(todo: VToDo): Task { - val t = Task() - - if (todo.uid != null) - t.uid = todo.uid.value - else { - logger.warning("Received VTODO without UID, generating new one") - t.generateUID() - } - - // sequence must only be null for locally created, not-yet-synchronized events - t.sequence = 0 - - for (prop in todo.properties) - when (prop) { - is Sequence -> t.sequence = prop.sequenceNo - is Created -> t.createdAt = prop.dateTime.time - is LastModified -> t.lastModified = prop.dateTime.time - is Summary -> t.summary = prop.value - is Location -> t.location = prop.value - is Geo -> t.geoPosition = prop - is Description -> t.description = prop.value - is Color -> t.color = Css3Color.fromString(prop.value)?.argb - is Url -> t.url = prop.value - is Organizer -> t.organizer = prop - is Priority -> t.priority = prop.level - is Clazz -> t.classification = prop - is Status -> t.status = prop - is Due -> { t.due = prop } - is Duration -> t.duration = prop - is DtStart -> { t.dtStart = prop } - is Completed -> { t.completedAt = prop } - is PercentComplete -> t.percentComplete = prop.percentage - is RRule -> t.rRule = prop - is RDate -> t.rDates += prop - is ExDate -> t.exDates += prop - is Categories -> - for (category in prop.categories) - t.categories += category - is Comment -> t.comment = prop.value - is RelatedTo -> t.relatedTo.add(prop) - is Uid, is ProdId, is DtStamp -> { /* don't save these as unknown properties */ } - else -> t.unknownProperties += prop - } - - t.alarms.addAll(todo.alarms) - - // There seem to be many invalid tasks out there because of some defect clients, do some validation. - val dtStart = t.dtStart - val due = t.due - - if (dtStart != null && due != null) { - if (DateUtils.isDate(dtStart) && DateUtils.isDateTime(due)) { - logger.warning("DTSTART is DATE but DUE is DATE-TIME, rewriting DTSTART to DATE-TIME") - t.dtStart = DtStart(DateTime(dtStart.value, due.timeZone)) - } else if (DateUtils.isDateTime(dtStart) && DateUtils.isDate(due)) { - logger.warning("DTSTART is DATE-TIME but DUE is DATE, rewriting DUE to DATE-TIME") - t.due = Due(DateTime(due.value, dtStart.timeZone)) - } - - - if (due.date <= dtStart.date) { - logger.warning("Found invalid DUE <= DTSTART; dropping DTSTART") - t.dtStart = null - } - } - - if (t.duration != null && t.dtStart == null) { - logger.warning("Found DURATION without DTSTART; ignoring") - t.duration = null - } - - return t - } - } diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt b/lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt new file mode 100644 index 00000000..fc44550d --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt @@ -0,0 +1,148 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import at.bitfire.ical4android.ICalendar.Companion.fromReader +import at.bitfire.ical4android.util.DateUtils +import at.bitfire.synctools.exception.InvalidICalendarException +import at.bitfire.synctools.icalendar.Css3Color +import net.fortuna.ical4j.model.Component +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.component.VToDo +import net.fortuna.ical4j.model.property.Categories +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.Color +import net.fortuna.ical4j.model.property.Comment +import net.fortuna.ical4j.model.property.Completed +import net.fortuna.ical4j.model.property.Created +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.DtStamp +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.Duration +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.Geo +import net.fortuna.ical4j.model.property.LastModified +import net.fortuna.ical4j.model.property.Location +import net.fortuna.ical4j.model.property.Organizer +import net.fortuna.ical4j.model.property.PercentComplete +import net.fortuna.ical4j.model.property.Priority +import net.fortuna.ical4j.model.property.ProdId +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.RelatedTo +import net.fortuna.ical4j.model.property.Sequence +import net.fortuna.ical4j.model.property.Status +import net.fortuna.ical4j.model.property.Summary +import net.fortuna.ical4j.model.property.Uid +import net.fortuna.ical4j.model.property.Url +import java.io.IOException +import java.io.Reader +import java.util.LinkedList +import java.util.logging.Logger + +/** + * Generates a single or list of [Task] from an iCalendar in a [Reader] source. + */ +class TaskReader { + + private val logger + get() = Logger.getLogger(TaskReader::class.java.name) + + /** + * Parses an iCalendar resource, applies [at.bitfire.synctools.icalendar.validation.ICalPreprocessor] to increase compatibility + * and extracts the VTODOs. + * + * @param reader where the iCalendar is taken from + * + * @return array of filled [Task] data objects (may have size 0) + * + * @throws InvalidICalendarException when the iCalendar can't be parsed + * @throws IOException on I/O errors + */ + fun tasksFromReader(reader: Reader): List { + val ical = fromReader(reader) + val vToDos = ical.getComponents(Component.VTODO) + return vToDos.mapTo(LinkedList()) { this.fromVToDo(it) } + } + + private fun fromVToDo(todo: VToDo): Task { + val t = Task() + + if (todo.uid != null) + t.uid = todo.uid.value + else { + logger.warning("Received VTODO without UID, generating new one") + t.generateUID() + } + + // sequence must only be null for locally created, not-yet-synchronized events + t.sequence = 0 + + for (prop in todo.properties) + when (prop) { + is Sequence -> t.sequence = prop.sequenceNo + is Created -> t.createdAt = prop.dateTime.time + is LastModified -> t.lastModified = prop.dateTime.time + is Summary -> t.summary = prop.value + is Location -> t.location = prop.value + is Geo -> t.geoPosition = prop + is Description -> t.description = prop.value + is Color -> t.color = Css3Color.fromString(prop.value)?.argb + is Url -> t.url = prop.value + is Organizer -> t.organizer = prop + is Priority -> t.priority = prop.level + is Clazz -> t.classification = prop + is Status -> t.status = prop + is Due -> { t.due = prop } + is Duration -> t.duration = prop + is DtStart -> { t.dtStart = prop } + is Completed -> { t.completedAt = prop } + is PercentComplete -> t.percentComplete = prop.percentage + is RRule -> t.rRule = prop + is RDate -> t.rDates += prop + is ExDate -> t.exDates += prop + is Categories -> + for (category in prop.categories) + t.categories += category + is Comment -> t.comment = prop.value + is RelatedTo -> t.relatedTo.add(prop) + is Uid, is ProdId, is DtStamp -> { /* don't save these as unknown properties */ } + else -> t.unknownProperties += prop + } + + t.alarms.addAll(todo.alarms) + + // There seem to be many invalid tasks out there because of some defect clients, do some validation. + val dtStart = t.dtStart + val due = t.due + + if (dtStart != null && due != null) { + if (DateUtils.isDate(dtStart) && DateUtils.isDateTime(due)) { + logger.warning("DTSTART is DATE but DUE is DATE-TIME, rewriting DTSTART to DATE-TIME") + t.dtStart = DtStart(DateTime(dtStart.value, due.timeZone)) + } else if (DateUtils.isDateTime(dtStart) && DateUtils.isDate(due)) { + logger.warning("DTSTART is DATE-TIME but DUE is DATE, rewriting DUE to DATE-TIME") + t.due = Due(DateTime(due.value, dtStart.timeZone)) + } + + + if (due.date <= dtStart.date) { + logger.warning("Found invalid DUE <= DTSTART; dropping DTSTART") + t.dtStart = null + } + } + + if (t.duration != null && t.dtStart == null) { + logger.warning("Found DURATION without DTSTART; ignoring") + t.duration = null + } + + return t + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt new file mode 100644 index 00000000..bc2ce0ef --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt @@ -0,0 +1,217 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import 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.parameter.RelType +import net.fortuna.ical4j.model.parameter.Value +import net.fortuna.ical4j.model.property.Clazz +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.Due +import net.fortuna.ical4j.model.property.ExDate +import net.fortuna.ical4j.model.property.ProdId +import net.fortuna.ical4j.model.property.RDate +import net.fortuna.ical4j.model.property.RRule +import net.fortuna.ical4j.model.property.Status +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStreamReader +import java.io.StringReader +import java.nio.charset.Charset +import java.time.Duration + +class TaskReaderTest { + + val testProdId = ProdId(javaClass.name) + val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()!! + val tzVienna: TimeZone = tzRegistry.getTimeZone("Europe/Vienna")!! + + @Test + fun testCharsets() { + var t = parseCalendarFile("latin1.ics", Charsets.ISO_8859_1) + assertEquals("äöüß", t.summary) + + t = parseCalendarFile("utf8.ics") + assertEquals("© äö — üß", t.summary) + assertEquals("中华人民共和国", t.location) + } + + @Test + fun testDtStartDate_DueDateTime() { + val t = parseCalendar("BEGIN:VCALENDAR\r\n" + + "VERSION 2:0\r\n" + + "BEGIN:VTODO\r\n" + + "SUMMARY:DTSTART is DATE, but DUE is DATE-TIME\r\n" + + "DTSTART;VALUE=DATE:20200731\r\n" + + "DUE;TZID=Europe/Vienna:20200731T234600\r\n" + + "END:VTODO\r\n" + + "END:VCALENDAR\r\n") + assertEquals("DTSTART is DATE, but DUE is DATE-TIME", t.summary) + // rewrite DTSTART to DATE-TIME, too + assertEquals(DtStart(DateTime("20200731T000000", tzVienna)), t.dtStart) + assertEquals(Due(DateTime("20200731T234600", tzVienna)), t.due) + } + + @Test + fun testDtStartDateTime_DueDate() { + val t = parseCalendar("BEGIN:VCALENDAR\r\n" + + "VERSION 2:0\r\n" + + "BEGIN:VTODO\r\n" + + "SUMMARY:DTSTART is DATE-TIME, but DUE is DATE\r\n" + + "DTSTART;TZID=Europe/Vienna:20200731T235510\r\n" + + "DUE;VALUE=DATE:20200801\r\n" + + "END:VTODO\r\n" + + "END:VCALENDAR\r\n") + assertEquals("DTSTART is DATE-TIME, but DUE is DATE", t.summary) + // rewrite DTSTART to DATE-TIME, too + assertEquals(DtStart(DateTime("20200731T235510", tzVienna)), t.dtStart) + assertEquals(Due(DateTime("20200801T000000", tzVienna)), t.due) + } + + @Test + fun testDueBeforeDtStart() { + val t = parseCalendar("BEGIN:VCALENDAR\r\n" + + "VERSION 2:0\r\n" + + "BEGIN:VTODO\r\n" + + "SUMMARY:DUE before DTSTART\r\n" + + "DTSTART;TZID=Europe/Vienna:20200731T234600\r\n" + + "DUE;TZID=Europe/Vienna:20200731T123000\r\n" + + "END:VTODO\r\n" + + "END:VCALENDAR\r\n") + assertEquals("DUE before DTSTART", t.summary) + // invalid tasks with DUE before DTSTART: DTSTART should be set to null + assertNull(t.dtStart) + assertEquals(Due(DateTime("20200731T123000", tzVienna)), t.due) + } + + @Test + fun testDurationWithoutDtStart() { + val t = parseCalendar("BEGIN:VCALENDAR\r\n" + + "VERSION 2:0\r\n" + + "BEGIN:VTODO\r\n" + + "SUMMARY:DURATION without DTSTART\r\n" + + "DURATION:PT1H\r\n" + + "END:VTODO\r\n" + + "END:VCALENDAR\r\n") + assertEquals("DURATION without DTSTART", t.summary) + assertNull(t.dtStart) + assertNull(t.duration) + } + + @Test + fun testEmptyPriority() { + val t = parseCalendar("BEGIN:VCALENDAR\r\n" + + "VERSION 2:0\r\n" + + "BEGIN:VTODO\r\n" + + "SUMMARY:Empty PRIORITY\r\n" + + "PRIORITY:\r\n" + + "END:VTODO\r\n" + + "END:VCALENDAR\r\n") + assertEquals("Empty PRIORITY", t.summary) + assertEquals(0, t.priority) + } + + + @Test + fun testSamples() { + val t = regenerate(parseCalendarFile("rfc5545-sample1.ics")) + assertEquals(2, t.sequence) + assertEquals("uid4@example.com", t.uid) + assertEquals("mailto:unclesam@example.com", t.organizer!!.value) + assertEquals(Due("19980415T000000"), t.due) + assertFalse(t.isAllDay()) + assertEquals(Status.VTODO_NEEDS_ACTION, t.status) + assertEquals("Submit Income Taxes", t.summary) + } + + @Test + fun testAllFields() { + // 1. parse the VTODO file + // 2. generate a new VTODO file from the parsed code + // 3. parse it again – so we can test parsing and generating at once + var t = regenerate(parseCalendarFile("most-fields1.ics")) + assertEquals(1, t.sequence) + assertEquals("most-fields1@example.com", t.uid) + assertEquals("Conference Room - F123, Bldg. 002", t.location) + assertEquals("37.386013", t.geoPosition!!.latitude.toPlainString()) + assertEquals("-122.082932", t.geoPosition!!.longitude.toPlainString()) + assertEquals( + "Meeting to provide technical review for \"Phoenix\" design.\nHappy Face Conference Room. Phoenix design team MUST attend this meeting.\nRSVP to team leader.", + t.description + ) + assertEquals("http://example.com/principals/jsmith", t.organizer!!.value) + assertEquals("http://example.com/pub/calendars/jsmith/mytime.ics", t.url) + assertEquals(1, t.priority) + assertEquals(Clazz.CONFIDENTIAL, t.classification) + assertEquals(Status.VTODO_IN_PROCESS, t.status) + assertEquals(25, t.percentComplete) + assertEquals(DtStart(Date("20100101")), t.dtStart) + assertEquals(Due(Date("20101001")), t.due) + assertTrue(t.isAllDay()) + + assertEquals(RRule("FREQ=YEARLY;INTERVAL=2"), t.rRule) + assertEquals(2, t.exDates.size) + assertTrue(t.exDates.contains(ExDate(DateList("20120101", Value.DATE)))) + assertTrue(t.exDates.contains(ExDate(DateList("20140101,20180101", Value.DATE)))) + assertEquals(2, t.rDates.size) + assertTrue(t.rDates.contains(RDate(DateList("20100310,20100315", Value.DATE)))) + assertTrue(t.rDates.contains(RDate(DateList("20100810", Value.DATE)))) + + assertEquals(828106200000L, t.createdAt) + assertEquals(840288600000L, t.lastModified) + + assertArrayEquals(arrayOf("Test", "Sample"), t.categories.toArray()) + + val (sibling) = t.relatedTo + assertEquals("most-fields2@example.com", sibling.value) + assertEquals(RelType.SIBLING, (sibling.getParameter(Parameter.RELTYPE) as RelType)) + + val (unknown) = t.unknownProperties + assertEquals("X-UNKNOWN-PROP", unknown.name) + assertEquals("xxx", unknown.getParameter("param1").value) + assertEquals("Unknown Value", unknown.value) + + // other file + t = regenerate(parseCalendarFile("most-fields2.ics")) + assertEquals("most-fields2@example.com", t.uid) + assertEquals(DtStart(DateTime("20100101T101010Z")), t.dtStart) + assertEquals( + net.fortuna.ical4j.model.property.Duration(Duration.ofSeconds(4 * 86400 + 3 * 3600 + 2 * 60 + 1) /*Dur(4, 3, 2, 1)*/), + t.duration + ) + assertTrue(t.unknownProperties.isEmpty()) + } + + + /* helpers */ + + private fun parseCalendar(iCalendar: String): Task = + TaskReader().tasksFromReader(StringReader(iCalendar)).first() + + private fun parseCalendarFile(fname: String, charset: Charset = Charsets.UTF_8): Task { + javaClass.classLoader!!.getResourceAsStream("tasks/$fname").use { stream -> + return TaskReader().tasksFromReader(InputStreamReader(stream, charset)).first() + } + } + + private fun regenerate(t: Task): Task { + val os = ByteArrayOutputStream() + t.write(os, testProdId) + return TaskReader().tasksFromReader(InputStreamReader(ByteArrayInputStream(os.toByteArray()), Charsets.UTF_8)).first() + } +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/TaskTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/TaskTest.kt index 4e90794d..a3b32ab8 100644 --- a/lib/src/test/kotlin/at/bitfire/ical4android/TaskTest.kt +++ b/lib/src/test/kotlin/at/bitfire/ical4android/TaskTest.kt @@ -7,34 +7,18 @@ package at.bitfire.ical4android 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 import net.fortuna.ical4j.model.property.Action -import net.fortuna.ical4j.model.property.Clazz import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Due -import net.fortuna.ical4j.model.property.ExDate import net.fortuna.ical4j.model.property.ProdId -import net.fortuna.ical4j.model.property.RDate -import net.fortuna.ical4j.model.property.RRule -import net.fortuna.ical4j.model.property.Status -import org.junit.Assert.assertArrayEquals -import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream -import java.io.InputStreamReader -import java.io.StringReader -import java.nio.charset.Charset import java.time.Duration class TaskTest { @@ -43,165 +27,6 @@ class TaskTest { val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()!! val tzBerlin: TimeZone = tzRegistry.getTimeZone("Europe/Berlin")!! - val tzVienna: TimeZone = tzRegistry.getTimeZone("Europe/Vienna")!! - - - /* public interface tests */ - - @Test - fun testCharsets() { - var t = parseCalendarFile("latin1.ics", Charsets.ISO_8859_1) - assertEquals("äöüß", t.summary) - - t = parseCalendarFile("utf8.ics") - assertEquals("© äö — üß", t.summary) - assertEquals("中华人民共和国", t.location) - } - - @Test - fun testDtStartDate_DueDateTime() { - val t = parseCalendar("BEGIN:VCALENDAR\r\n" + - "VERSION 2:0\r\n" + - "BEGIN:VTODO\r\n" + - "SUMMARY:DTSTART is DATE, but DUE is DATE-TIME\r\n" + - "DTSTART;VALUE=DATE:20200731\r\n" + - "DUE;TZID=Europe/Vienna:20200731T234600\r\n" + - "END:VTODO\r\n" + - "END:VCALENDAR\r\n") - assertEquals("DTSTART is DATE, but DUE is DATE-TIME", t.summary) - // rewrite DTSTART to DATE-TIME, too - assertEquals(DtStart(DateTime("20200731T000000", tzVienna)), t.dtStart) - assertEquals(Due(DateTime("20200731T234600", tzVienna)), t.due) - } - - @Test - fun testDtStartDateTime_DueDate() { - val t = parseCalendar("BEGIN:VCALENDAR\r\n" + - "VERSION 2:0\r\n" + - "BEGIN:VTODO\r\n" + - "SUMMARY:DTSTART is DATE-TIME, but DUE is DATE\r\n" + - "DTSTART;TZID=Europe/Vienna:20200731T235510\r\n" + - "DUE;VALUE=DATE:20200801\r\n" + - "END:VTODO\r\n" + - "END:VCALENDAR\r\n") - assertEquals("DTSTART is DATE-TIME, but DUE is DATE", t.summary) - // rewrite DTSTART to DATE-TIME, too - assertEquals(DtStart(DateTime("20200731T235510", tzVienna)), t.dtStart) - assertEquals(Due(DateTime("20200801T000000", tzVienna)), t.due) - } - - @Test - fun testDueBeforeDtStart() { - val t = parseCalendar("BEGIN:VCALENDAR\r\n" + - "VERSION 2:0\r\n" + - "BEGIN:VTODO\r\n" + - "SUMMARY:DUE before DTSTART\r\n" + - "DTSTART;TZID=Europe/Vienna:20200731T234600\r\n" + - "DUE;TZID=Europe/Vienna:20200731T123000\r\n" + - "END:VTODO\r\n" + - "END:VCALENDAR\r\n") - assertEquals("DUE before DTSTART", t.summary) - // invalid tasks with DUE before DTSTART: DTSTART should be set to null - assertNull(t.dtStart) - assertEquals(Due(DateTime("20200731T123000", tzVienna)), t.due) - } - - @Test - fun testDurationWithoutDtStart() { - val t = parseCalendar("BEGIN:VCALENDAR\r\n" + - "VERSION 2:0\r\n" + - "BEGIN:VTODO\r\n" + - "SUMMARY:DURATION without DTSTART\r\n" + - "DURATION:PT1H\r\n" + - "END:VTODO\r\n" + - "END:VCALENDAR\r\n") - assertEquals("DURATION without DTSTART", t.summary) - assertNull(t.dtStart) - assertNull(t.duration) - } - - @Test - fun testEmptyPriority() { - val t = parseCalendar("BEGIN:VCALENDAR\r\n" + - "VERSION 2:0\r\n" + - "BEGIN:VTODO\r\n" + - "SUMMARY:Empty PRIORITY\r\n" + - "PRIORITY:\r\n" + - "END:VTODO\r\n" + - "END:VCALENDAR\r\n") - assertEquals("Empty PRIORITY", t.summary) - assertEquals(0, t.priority) - } - - @Test - fun testSamples() { - val t = regenerate(parseCalendarFile("rfc5545-sample1.ics")) - assertEquals(2, t.sequence) - assertEquals("uid4@example.com", t.uid) - assertEquals("mailto:unclesam@example.com", t.organizer!!.value) - assertEquals(Due("19980415T000000"), t.due) - assertFalse(t.isAllDay()) - assertEquals(Status.VTODO_NEEDS_ACTION, t.status) - assertEquals("Submit Income Taxes", t.summary) - } - - @Test - fun testAllFields() { - // 1. parse the VTODO file - // 2. generate a new VTODO file from the parsed code - // 3. parse it again – so we can test parsing and generating at once - var t = regenerate(parseCalendarFile("most-fields1.ics")) - assertEquals(1, t.sequence) - assertEquals("most-fields1@example.com", t.uid) - assertEquals("Conference Room - F123, Bldg. 002", t.location) - assertEquals("37.386013", t.geoPosition!!.latitude.toPlainString()) - assertEquals("-122.082932", t.geoPosition!!.longitude.toPlainString()) - assertEquals( - "Meeting to provide technical review for \"Phoenix\" design.\nHappy Face Conference Room. Phoenix design team MUST attend this meeting.\nRSVP to team leader.", - t.description - ) - assertEquals("http://example.com/principals/jsmith", t.organizer!!.value) - assertEquals("http://example.com/pub/calendars/jsmith/mytime.ics", t.url) - assertEquals(1, t.priority) - assertEquals(Clazz.CONFIDENTIAL, t.classification) - assertEquals(Status.VTODO_IN_PROCESS, t.status) - assertEquals(25, t.percentComplete) - assertEquals(DtStart(Date("20100101")), t.dtStart) - assertEquals(Due(Date("20101001")), t.due) - assertTrue(t.isAllDay()) - - assertEquals(RRule("FREQ=YEARLY;INTERVAL=2"), t.rRule) - assertEquals(2, t.exDates.size) - assertTrue(t.exDates.contains(ExDate(DateList("20120101", Value.DATE)))) - assertTrue(t.exDates.contains(ExDate(DateList("20140101,20180101", Value.DATE)))) - assertEquals(2, t.rDates.size) - assertTrue(t.rDates.contains(RDate(DateList("20100310,20100315", Value.DATE)))) - assertTrue(t.rDates.contains(RDate(DateList("20100810", Value.DATE)))) - - assertEquals(828106200000L, t.createdAt) - assertEquals(840288600000L, t.lastModified) - - assertArrayEquals(arrayOf("Test", "Sample"), t.categories.toArray()) - - val (sibling) = t.relatedTo - assertEquals("most-fields2@example.com", sibling.value) - assertEquals(RelType.SIBLING, (sibling.getParameter(Parameter.RELTYPE) as RelType)) - - val (unknown) = t.unknownProperties - assertEquals("X-UNKNOWN-PROP", unknown.name) - assertEquals("xxx", unknown.getParameter("param1").value) - assertEquals("Unknown Value", unknown.value) - - // other file - t = regenerate(parseCalendarFile("most-fields2.ics")) - assertEquals("most-fields2@example.com", t.uid) - assertEquals(DtStart(DateTime("20100101T101010Z")), t.dtStart) - assertEquals( - net.fortuna.ical4j.model.property.Duration(Duration.ofSeconds(4 * 86400 + 3 * 3600 + 2 * 60 + 1) /*Dur(4, 3, 2, 1)*/), - t.duration - ) - assertTrue(t.unknownProperties.isEmpty()) - } /* generating */ @@ -267,22 +92,4 @@ class TaskTest { }.isAllDay()) } - - /* helpers */ - - private fun parseCalendar(iCalendar: String): Task = - Task.tasksFromReader(StringReader(iCalendar)).first() - - private fun parseCalendarFile(fname: String, charset: Charset = Charsets.UTF_8): Task { - javaClass.classLoader!!.getResourceAsStream("tasks/$fname").use { stream -> - return Task.tasksFromReader(InputStreamReader(stream, charset)).first() - } - } - - private fun regenerate(t: Task): Task { - val os = ByteArrayOutputStream() - t.write(os, testProdId) - return Task.tasksFromReader(InputStreamReader(ByteArrayInputStream(os.toByteArray()), Charsets.UTF_8)).first() - } - } \ No newline at end of file From b6cd1cc6ac9a4b56a3cd1bea657ecbcf7cb6dc80 Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 29 Jan 2026 14:57:27 +0100 Subject: [PATCH 2/3] Extract task writing/generation from data class --- .../kotlin/at/bitfire/ical4android/Task.kt | 128 +--------------- .../at/bitfire/ical4android/TaskWriter.kt | 141 ++++++++++++++++++ .../at/bitfire/ical4android/TaskReaderTest.kt | 9 +- .../at/bitfire/ical4android/TaskTest.kt | 47 ------ .../at/bitfire/ical4android/TaskWriterTest.kt | 57 +++++++ 5 files changed, 205 insertions(+), 177 deletions(-) create mode 100644 lib/src/main/kotlin/at/bitfire/ical4android/TaskWriter.kt create mode 100644 lib/src/test/kotlin/at/bitfire/ical4android/TaskWriterTest.kt diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt index 5183265c..381d3aa7 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/Task.kt @@ -8,48 +8,22 @@ package at.bitfire.ical4android import androidx.annotation.IntRange import at.bitfire.ical4android.util.DateUtils -import at.bitfire.synctools.icalendar.Css3Color -import net.fortuna.ical4j.data.CalendarOutputter -import net.fortuna.ical4j.model.Calendar -import net.fortuna.ical4j.model.DateTime import net.fortuna.ical4j.model.Property -import net.fortuna.ical4j.model.TextList -import net.fortuna.ical4j.model.TimeZone import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.component.VToDo -import net.fortuna.ical4j.model.property.Categories import net.fortuna.ical4j.model.property.Clazz -import net.fortuna.ical4j.model.property.Color -import net.fortuna.ical4j.model.property.Comment import net.fortuna.ical4j.model.property.Completed -import net.fortuna.ical4j.model.property.Created -import net.fortuna.ical4j.model.property.Description import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Due import net.fortuna.ical4j.model.property.Duration import net.fortuna.ical4j.model.property.ExDate import net.fortuna.ical4j.model.property.Geo -import net.fortuna.ical4j.model.property.LastModified -import net.fortuna.ical4j.model.property.Location import net.fortuna.ical4j.model.property.Organizer -import net.fortuna.ical4j.model.property.PercentComplete import net.fortuna.ical4j.model.property.Priority -import net.fortuna.ical4j.model.property.ProdId import net.fortuna.ical4j.model.property.RDate import net.fortuna.ical4j.model.property.RRule import net.fortuna.ical4j.model.property.RelatedTo -import net.fortuna.ical4j.model.property.Sequence import net.fortuna.ical4j.model.property.Status -import net.fortuna.ical4j.model.property.Summary -import net.fortuna.ical4j.model.property.Uid -import net.fortuna.ical4j.model.property.Url -import net.fortuna.ical4j.model.property.Version -import java.io.OutputStream -import java.net.URI -import java.net.URISyntaxException import java.util.LinkedList -import java.util.logging.Level -import java.util.logging.Logger /** * Data class representing a task @@ -95,106 +69,10 @@ data class Task( val alarms: LinkedList = LinkedList(), ) : ICalendar() { - companion object { - - private val logger - get() = Logger.getLogger(Task::class.java.name) - - } - - - /** - * 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.withUserAgents(userAgents) - - val vTodo = VToDo(true /* generates DTSTAMP */) - ical.components += vTodo - val props = vTodo.properties - - uid?.let { props += Uid(uid) } - sequence?.let { - if (it != 0) - props += Sequence(it) - } - - createdAt?.let { props += Created(DateTime(it)) } - lastModified?.let { props += LastModified(DateTime(it)) } - - summary?.let { props += Summary(it) } - location?.let { props += Location(it) } - geoPosition?.let { props += it } - description?.let { props += Description(it) } - color?.let { props += Color(null, Css3Color.nearestMatch(it).name) } - url?.let { - try { - props += Url(URI(it)) - } catch (e: URISyntaxException) { - logger.log(Level.WARNING, "Ignoring invalid task URL: $url", e) - } - } - organizer?.let { props += it } - - if (priority != Priority.UNDEFINED.level) - props += Priority(priority) - classification?.let { props += it } - status?.let { props += it } - - rRule?.let { props += it } - rDates.forEach { props += it } - exDates.forEach { props += it } - - if (categories.isNotEmpty()) - props += Categories(TextList(categories.toTypedArray())) - comment?.let { props += Comment(it) } - props.addAll(relatedTo) - props.addAll(unknownProperties) - - // remember used time zones - val usedTimeZones = HashSet() - due?.let { - props += it - it.timeZone?.let(usedTimeZones::add) - } - duration?.let(props::add) - dtStart?.let { - props += it - it.timeZone?.let(usedTimeZones::add) - } - completedAt?.let { - props += it - it.timeZone?.let(usedTimeZones::add) - } - percentComplete?.let { props += PercentComplete(it) } - - if (alarms.isNotEmpty()) - vTodo.components.addAll(alarms) - - // determine earliest referenced date - val earliest = arrayOf( - dtStart?.date, - due?.date, - completedAt?.date - ).filterNotNull().minOrNull() - // add VTIMEZONE components - for (tz in usedTimeZones) - ical.components += minifyVTimeZone(tz.vTimeZone, earliest) - - softValidate(ical) - CalendarOutputter(false).output(ical, os) - } - - fun isAllDay(): Boolean { - return dtStart?.let { DateUtils.isDate(it) } ?: - due?.let { DateUtils.isDate(it) } ?: - true + return dtStart?.let { DateUtils.isDate(it) } + ?: due?.let { DateUtils.isDate(it) } + ?: true } } diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/TaskWriter.kt b/lib/src/main/kotlin/at/bitfire/ical4android/TaskWriter.kt new file mode 100644 index 00000000..876f3312 --- /dev/null +++ b/lib/src/main/kotlin/at/bitfire/ical4android/TaskWriter.kt @@ -0,0 +1,141 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import at.bitfire.ical4android.ICalendar.Companion.minifyVTimeZone +import at.bitfire.ical4android.ICalendar.Companion.softValidate +import at.bitfire.ical4android.ICalendar.Companion.withUserAgents +import at.bitfire.synctools.icalendar.Css3Color +import net.fortuna.ical4j.data.CalendarOutputter +import net.fortuna.ical4j.model.Calendar +import net.fortuna.ical4j.model.DateTime +import net.fortuna.ical4j.model.TextList +import net.fortuna.ical4j.model.TimeZone +import net.fortuna.ical4j.model.component.VToDo +import net.fortuna.ical4j.model.property.Categories +import net.fortuna.ical4j.model.property.Color +import net.fortuna.ical4j.model.property.Comment +import net.fortuna.ical4j.model.property.Created +import net.fortuna.ical4j.model.property.Description +import net.fortuna.ical4j.model.property.LastModified +import net.fortuna.ical4j.model.property.Location +import net.fortuna.ical4j.model.property.PercentComplete +import net.fortuna.ical4j.model.property.Priority +import net.fortuna.ical4j.model.property.ProdId +import net.fortuna.ical4j.model.property.Sequence +import net.fortuna.ical4j.model.property.Summary +import net.fortuna.ical4j.model.property.Uid +import net.fortuna.ical4j.model.property.Url +import net.fortuna.ical4j.model.property.Version +import java.io.Writer +import java.net.URI +import java.net.URISyntaxException +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Writes a [Task] data class to a stream that contains an iCalendar + * (VCALENDAR with VTODOs and optional VTIMEZONEs). + * + * @param prodId PRODID to use in iCalendar, which identifies DAVx⁵ + */ +class TaskWriter( + private val prodId: ProdId +) { + + private val logger + get() = Logger.getLogger(TaskWriter::class.java.name) + + + /** + * Generates an iCalendar from the provided Task. + * + * @param task task to write + * @param to stream that the iCalendar is written to + */ + fun write(task: Task, to: Writer): Unit = with(task) { + val ical = Calendar() + ical.properties += Version.VERSION_2_0 + ical.properties += prodId.withUserAgents(userAgents) + + val vTodo = VToDo(true /* generates DTSTAMP */) + ical.components += vTodo + val props = vTodo.properties + + uid?.let { props += Uid(uid) } + sequence?.let { + if (it != 0) + props += Sequence(it) + } + + createdAt?.let { props += Created(DateTime(it)) } + lastModified?.let { props += LastModified(DateTime(it)) } + + summary?.let { props += Summary(it) } + location?.let { props += Location(it) } + geoPosition?.let { props += it } + description?.let { props += Description(it) } + color?.let { props += Color(null, Css3Color.nearestMatch(it).name) } + url?.let { + try { + props += Url(URI(it)) + } catch (e: URISyntaxException) { + logger.log(Level.WARNING, "Ignoring invalid task URL: $url", e) + } + } + organizer?.let { props += it } + + if (priority != Priority.UNDEFINED.level) + props += Priority(priority) + classification?.let { props += it } + status?.let { props += it } + + rRule?.let { props += it } + rDates.forEach { props += it } + exDates.forEach { props += it } + + if (categories.isNotEmpty()) + props += Categories(TextList(categories.toTypedArray())) + comment?.let { props += Comment(it) } + props.addAll(relatedTo) + props.addAll(unknownProperties) + + // remember used time zones + val usedTimeZones = HashSet() + due?.let { + props += it + it.timeZone?.let(usedTimeZones::add) + } + duration?.let(props::add) + dtStart?.let { + props += it + it.timeZone?.let(usedTimeZones::add) + } + completedAt?.let { + props += it + it.timeZone?.let(usedTimeZones::add) + } + percentComplete?.let { props += PercentComplete(it) } + + if (alarms.isNotEmpty()) + vTodo.components.addAll(alarms) + + // determine earliest referenced date + val earliest = arrayOf( + dtStart?.date, + due?.date, + completedAt?.date + ).filterNotNull().minOrNull() + // add VTIMEZONE components + for (tz in usedTimeZones) + ical.components += minifyVTimeZone(tz.vTimeZone, earliest) + + softValidate(ical) + CalendarOutputter(false).output(ical, to) + } + +} \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt index bc2ce0ef..51e8dd87 100644 --- a/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt @@ -28,10 +28,9 @@ import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream import java.io.InputStreamReader import java.io.StringReader +import java.io.StringWriter import java.nio.charset.Charset import java.time.Duration @@ -210,8 +209,8 @@ class TaskReaderTest { } private fun regenerate(t: Task): Task { - val os = ByteArrayOutputStream() - t.write(os, testProdId) - return TaskReader().tasksFromReader(InputStreamReader(ByteArrayInputStream(os.toByteArray()), Charsets.UTF_8)).first() + val icalWriter = StringWriter() + TaskWriter(testProdId).write(t, icalWriter) + return TaskReader().tasksFromReader(StringReader(icalWriter.toString())).first() } } \ No newline at end of file diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/TaskTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/TaskTest.kt index a3b32ab8..977ed15e 100644 --- a/lib/src/test/kotlin/at/bitfire/ical4android/TaskTest.kt +++ b/lib/src/test/kotlin/at/bitfire/ical4android/TaskTest.kt @@ -8,61 +8,14 @@ package at.bitfire.ical4android import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.DateTime -import net.fortuna.ical4j.model.TimeZone -import net.fortuna.ical4j.model.TimeZoneRegistryFactory -import net.fortuna.ical4j.model.component.VAlarm -import net.fortuna.ical4j.model.property.Action import net.fortuna.ical4j.model.property.DtStart import net.fortuna.ical4j.model.property.Due -import net.fortuna.ical4j.model.property.ProdId import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -import java.io.ByteArrayOutputStream -import java.time.Duration class TaskTest { - val testProdId = ProdId(javaClass.name) - - val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()!! - val tzBerlin: TimeZone = tzRegistry.getTimeZone("Europe/Berlin")!! - - - /* generating */ - - @Test - fun testWrite() { - val t = Task() - t.uid = "SAMPLEUID" - t.dtStart = DtStart("20190101T100000", tzBerlin) - - val alarm = VAlarm(Duration.ofHours(-1) /*Dur(0, -1, 0, 0)*/) - alarm.properties += Action.AUDIO - t.alarms += alarm - - val os = ByteArrayOutputStream() - t.write(os, testProdId) - val raw = os.toString(Charsets.UTF_8.name()) - - assertTrue(raw.contains("PRODID:${testProdId.value}")) - assertTrue(raw.contains("UID:SAMPLEUID")) - assertTrue(raw.contains("DTSTAMP:")) - assertTrue(raw.contains("DTSTART;TZID=Europe/Berlin:20190101T100000")) - assertTrue( - raw.contains( - "BEGIN:VALARM\r\n" + - "TRIGGER:-PT1H\r\n" + - "ACTION:AUDIO\r\n" + - "END:VALARM\r\n" - ) - ) - assertTrue(raw.contains("BEGIN:VTIMEZONE")) - } - - - /* other methods */ - @Test fun testAllDay() { assertTrue(Task().isAllDay()) diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/TaskWriterTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/TaskWriterTest.kt new file mode 100644 index 00000000..b9095e2c --- /dev/null +++ b/lib/src/test/kotlin/at/bitfire/ical4android/TaskWriterTest.kt @@ -0,0 +1,57 @@ +/* + * This file is part of bitfireAT/synctools which is released under GPLv3. + * Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details. + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package at.bitfire.ical4android + +import net.fortuna.ical4j.model.TimeZone +import net.fortuna.ical4j.model.TimeZoneRegistryFactory +import net.fortuna.ical4j.model.component.VAlarm +import net.fortuna.ical4j.model.property.Action +import net.fortuna.ical4j.model.property.DtStart +import net.fortuna.ical4j.model.property.ProdId +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.StringWriter +import java.time.Duration + +class TaskWriterTest { + + val testProdId = ProdId(javaClass.name) + + val tzRegistry = TimeZoneRegistryFactory.getInstance().createRegistry()!! + val tzBerlin: TimeZone = tzRegistry.getTimeZone("Europe/Berlin")!! + + + @Test + fun testWrite() { + val t = Task() + t.uid = "SAMPLEUID" + t.dtStart = DtStart("20190101T100000", tzBerlin) + + val alarm = VAlarm(Duration.ofHours(-1) /*Dur(0, -1, 0, 0)*/) + alarm.properties += Action.AUDIO + t.alarms += alarm + + val icalWriter = StringWriter() + TaskWriter(testProdId).write(t, icalWriter) + val raw = icalWriter.toString() + + assertTrue(raw.contains("PRODID:${testProdId.value}")) + assertTrue(raw.contains("UID:SAMPLEUID")) + assertTrue(raw.contains("DTSTAMP:")) + assertTrue(raw.contains("DTSTART;TZID=Europe/Berlin:20190101T100000")) + assertTrue( + raw.contains( + "BEGIN:VALARM\r\n" + + "TRIGGER:-PT1H\r\n" + + "ACTION:AUDIO\r\n" + + "END:VALARM\r\n" + ) + ) + assertTrue(raw.contains("BEGIN:VTIMEZONE")) + } + +} \ No newline at end of file From 89e4813463a53815ec34073e99e17525e861ca9f Mon Sep 17 00:00:00 2001 From: Sunik Kupfer Date: Thu, 29 Jan 2026 15:03:19 +0100 Subject: [PATCH 3/3] Rename method --- lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt | 2 +- .../test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt b/lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt index fc44550d..c8c2685e 100644 --- a/lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt +++ b/lib/src/main/kotlin/at/bitfire/ical4android/TaskReader.kt @@ -64,7 +64,7 @@ class TaskReader { * @throws InvalidICalendarException when the iCalendar can't be parsed * @throws IOException on I/O errors */ - fun tasksFromReader(reader: Reader): List { + fun readTasks(reader: Reader): List { val ical = fromReader(reader) val vToDos = ical.getComponents(Component.VTODO) return vToDos.mapTo(LinkedList()) { this.fromVToDo(it) } diff --git a/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt b/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt index 51e8dd87..8360c02a 100644 --- a/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt +++ b/lib/src/test/kotlin/at/bitfire/ical4android/TaskReaderTest.kt @@ -200,17 +200,17 @@ class TaskReaderTest { /* helpers */ private fun parseCalendar(iCalendar: String): Task = - TaskReader().tasksFromReader(StringReader(iCalendar)).first() + TaskReader().readTasks(StringReader(iCalendar)).first() private fun parseCalendarFile(fname: String, charset: Charset = Charsets.UTF_8): Task { javaClass.classLoader!!.getResourceAsStream("tasks/$fname").use { stream -> - return TaskReader().tasksFromReader(InputStreamReader(stream, charset)).first() + return TaskReader().readTasks(InputStreamReader(stream, charset)).first() } } private fun regenerate(t: Task): Task { val icalWriter = StringWriter() TaskWriter(testProdId).write(t, icalWriter) - return TaskReader().tasksFromReader(StringReader(icalWriter.toString())).first() + return TaskReader().readTasks(StringReader(icalWriter.toString())).first() } } \ No newline at end of file