From 9dce2e4cd10f776cb31650a850d2929aee5a885e Mon Sep 17 00:00:00 2001 From: Robert Ros Date: Wed, 18 Feb 2026 09:02:43 +0100 Subject: [PATCH 1/9] Extract content line folding and extract normalizing of newlines and escaping logic for TEXT values. --- .../fossify/calendar/helpers/IcsExporter.kt | 136 ++++++++++-------- 1 file changed, 79 insertions(+), 57 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt index 3a71f2c8f..8952c1252 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt @@ -61,9 +61,9 @@ class IcsExporter(private val context: Context) { write(lineSeparator) } }.use { out -> - out.writeLn(BEGIN_CALENDAR) - out.writeLn(CALENDAR_PRODID) - out.writeLn(CALENDAR_VERSION) + out.writeContentLine(BEGIN_CALENDAR) + out.writeContentLine(CALENDAR_PRODID) + out.writeContentLine(CALENDAR_VERSION) for (event in events) { if (event.isTask()) { writeTask(out, event) @@ -71,7 +71,7 @@ class IcsExporter(private val context: Context) { writeEvent(out, event) } } - out.writeLn(END_CALENDAR) + out.writeContentLine(END_CALENDAR) } callback( @@ -88,52 +88,54 @@ class IcsExporter(private val context: Context) { event.getReminders().forEach { val reminder = it out.apply { - writeLn(BEGIN_ALARM) - writeLn("$DESCRIPTION_EXPORT$reminderLabel") + writeContentLine(BEGIN_ALARM) + writeContentLine("$DESCRIPTION_EXPORT$reminderLabel") if (reminder.type == REMINDER_NOTIFICATION) { - writeLn("$ACTION$DISPLAY") + writeContentLine("$ACTION$DISPLAY") } else { - writeLn("$ACTION$EMAIL") + writeContentLine("$ACTION$EMAIL") val attendee = calendars.firstOrNull { it.id == event.getCalDAVCalendarId() }?.accountName if (attendee != null) { - writeLn("$ATTENDEE$MAILTO$attendee") + writeContentLine("$ATTENDEE$MAILTO$attendee") } } val sign = if (reminder.minutes < -1) "" else "-" - writeLn("$TRIGGER:$sign${Parser().getDurationCode(Math.abs(reminder.minutes.toLong()))}") - writeLn(END_ALARM) + writeContentLine("$TRIGGER:$sign${Parser().getDurationCode(Math.abs(reminder.minutes.toLong()))}") + writeContentLine(END_ALARM) } } } private fun fillIgnoredOccurrences(event: Event, out: BufferedWriter) { event.repetitionExceptions.forEach { - out.writeLn("$EXDATE:$it") + out.writeContentLine("$EXDATE:$it") } } - private fun fillDescription(description: String, out: BufferedWriter) { + private fun foldContentLine(line: String): Sequence = sequence { var index = 0 var isFirstLine = true - while (index < description.length) { + while (index < line.length) { var end = index + MAX_LINE_LENGTH - if (end > description.length) { - end = description.length + // Take the prepended space into account. + if (!isFirstLine) end-- + if (end > line.length) { + end = line.length } else { // Avoid splitting surrogate pairs - if (Character.isHighSurrogate(description[end - 1])) { + if (Character.isHighSurrogate(line[end - 1])) { end-- } } - val substring = description.substring(index, end) + val substring = line.substring(index, end) if (isFirstLine) { - out.writeLn("$DESCRIPTION_EXPORT$substring") + yield(substring) } else { - out.writeLn(" $substring") + yield(" $substring") } isFirstLine = false @@ -144,21 +146,21 @@ class IcsExporter(private val context: Context) { private fun writeEvent(writer: BufferedWriter, event: Event) { val calendarColors = context.eventsHelper.getCalendarColors() with(writer) { - writeLn(BEGIN_EVENT) - event.title.replace("\n", "\\n").let { if (it.isNotEmpty()) writeLn("$SUMMARY:$it") } - event.importId.let { if (it.isNotEmpty()) writeLn("$UID$it") } - writeLn("$CATEGORY_COLOR${context.calendarsDB.getCalendarWithId(event.calendarId)?.color}") + writeContentLine(BEGIN_EVENT) + event.title.let { if (it.isNotEmpty()) writeTextProperty(SUMMARY, it) } + event.importId.let { if (it.isNotEmpty()) writeContentLine("$UID$it") } + writeContentLine("$CATEGORY_COLOR${context.calendarsDB.getCalendarWithId(event.calendarId)?.color}") if (event.color != 0 && event.color != calendarColors[event.calendarId]) { val color = CssColors.findClosestCssColor(event.color) if (color != null) { - writeLn("$COLOR${color}") + writeContentLine("$COLOR${color}") } - writeLn("$FOSSIFY_COLOR${event.color}") + writeContentLine("$FOSSIFY_COLOR${event.color}") } - writeLn("$CATEGORIES${context.calendarsDB.getCalendarWithId(event.calendarId)?.title}") - writeLn("$LAST_MODIFIED:${Formatter.getExportedTime(event.lastUpdated)}") - writeLn("$TRANSP${if (event.availability == Events.AVAILABILITY_FREE) TRANSPARENT else OPAQUE}") - event.location.let { if (it.isNotEmpty()) writeLn("$LOCATION:$it") } + writeContentLine("$CATEGORIES${context.calendarsDB.getCalendarWithId(event.calendarId)?.title}") + writeContentLine("$LAST_MODIFIED:${Formatter.getExportedTime(event.lastUpdated)}") + writeContentLine("$TRANSP${if (event.availability == Events.AVAILABILITY_FREE) TRANSPARENT else OPAQUE}") + event.location.let { if (it.isNotEmpty()) writeContentLine("$LOCATION:$it") } if (event.getIsAllDay()) { val tz = try { @@ -166,8 +168,8 @@ class IcsExporter(private val context: Context) { } catch (ignored: IllegalArgumentException) { DateTimeZone.getDefault() } - writeLn("$DTSTART;$VALUE=$DATE:${Formatter.getDayCodeFromTS(event.startTS, tz)}") - writeLn( + writeContentLine("$DTSTART;$VALUE=$DATE:${Formatter.getDayCodeFromTS(event.startTS, tz)}") + writeContentLine( "$DTEND;$VALUE=$DATE:${ Formatter.getDayCodeFromTS( event.endTS + TWELVE_HOURS, @@ -176,61 +178,81 @@ class IcsExporter(private val context: Context) { }" ) } else { - writeLn("$DTSTART:${Formatter.getExportedTime(event.startTS * 1000L)}") - writeLn("$DTEND:${Formatter.getExportedTime(event.endTS * 1000L)}") + writeContentLine("$DTSTART:${Formatter.getExportedTime(event.startTS * 1000L)}") + writeContentLine("$DTEND:${Formatter.getExportedTime(event.endTS * 1000L)}") } - writeLn("$MISSING_YEAR${if (event.hasMissingYear()) 1 else 0}") + writeContentLine("$MISSING_YEAR${if (event.hasMissingYear()) 1 else 0}") - writeLn("$DTSTAMP$exportTime") - writeLn("$CLASS:${getAccessLevelStringFromEventAccessLevel(event.accessLevel)}") - writeLn("$STATUS${getStatusStringFromEventStatus(event.status)}") - Parser().getRepeatCode(event).let { if (it.isNotEmpty()) writeLn("$RRULE$it") } + writeContentLine("$DTSTAMP$exportTime") + writeContentLine("$CLASS:${getAccessLevelStringFromEventAccessLevel(event.accessLevel)}") + writeContentLine("$STATUS${getStatusStringFromEventStatus(event.status)}") + Parser().getRepeatCode(event).let { if (it.isNotEmpty()) writeContentLine("$RRULE$it") } - fillDescription(event.description.replace("\n", "\\n"), writer) + writeTextProperty(DESCRIPTION, event.description) fillReminders(event, writer, reminderLabel) fillIgnoredOccurrences(event, writer) eventsExported++ - writeLn(END_EVENT) + writeContentLine(END_EVENT) } } private fun writeTask(writer: BufferedWriter, task: Event) { val calendarColors = context.eventsHelper.getCalendarColors() with(writer) { - writeLn(BEGIN_TASK) - task.title.replace("\n", "\\n").let { if (it.isNotEmpty()) writeLn("$SUMMARY:$it") } - task.importId.let { if (it.isNotEmpty()) writeLn("$UID$it") } - writeLn("$CATEGORY_COLOR${context.calendarsDB.getCalendarWithId(task.calendarId)?.color}") + writeContentLine(BEGIN_TASK) + task.title.let { if (it.isNotEmpty()) writeTextProperty(SUMMARY, it) } + task.importId.let { if (it.isNotEmpty()) writeContentLine("$UID$it") } + writeContentLine("$CATEGORY_COLOR${context.calendarsDB.getCalendarWithId(task.calendarId)?.color}") if (task.color != 0 && task.color != calendarColors[task.calendarId]) { val color = CssColors.findClosestCssColor(task.color) if (color != null) { - writeLn("$COLOR${color}") + writeContentLine("$COLOR${color}") } - writeLn("$FOSSIFY_COLOR${task.color}") + writeContentLine("$FOSSIFY_COLOR${task.color}") } - writeLn("$CATEGORIES${context.calendarsDB.getCalendarWithId(task.calendarId)?.title}") - writeLn("$LAST_MODIFIED:${Formatter.getExportedTime(task.lastUpdated)}") - task.location.let { if (it.isNotEmpty()) writeLn("$LOCATION:$it") } + writeContentLine("$CATEGORIES${context.calendarsDB.getCalendarWithId(task.calendarId)?.title}") + writeContentLine("$LAST_MODIFIED:${Formatter.getExportedTime(task.lastUpdated)}") + task.location.let { if (it.isNotEmpty()) writeContentLine("$LOCATION:$it") } if (task.getIsAllDay()) { - writeLn("$DTSTART;$VALUE=$DATE:${Formatter.getDayCodeFromTS(task.startTS)}") + writeContentLine("$DTSTART;$VALUE=$DATE:${Formatter.getDayCodeFromTS(task.startTS)}") } else { - writeLn("$DTSTART:${Formatter.getExportedTime(task.startTS * 1000L)}") + writeContentLine("$DTSTART:${Formatter.getExportedTime(task.startTS * 1000L)}") } - writeLn("$DTSTAMP$exportTime") + writeContentLine("$DTSTAMP$exportTime") if (task.isTaskCompleted()) { - writeLn("$STATUS$COMPLETED") + writeContentLine("$STATUS$COMPLETED") } - Parser().getRepeatCode(task).let { if (it.isNotEmpty()) writeLn("$RRULE$it") } + Parser().getRepeatCode(task).let { if (it.isNotEmpty()) writeContentLine("$RRULE$it") } - fillDescription(task.description.replace("\n", "\\n"), writer) + writeTextProperty(DESCRIPTION, task.description) fillReminders(task, writer, reminderLabel) fillIgnoredOccurrences(task, writer) eventsExported++ - writeLn(END_TASK) + writeContentLine(END_TASK) } } + + private fun BufferedWriter.writeContentLine(line: String) { + for (segment in foldContentLine(line)) { + this.writeLn(segment) + } + } + + private fun BufferedWriter.writeTextProperty(name: String, value: String) { + val normalizedValue = value + .replace("\r\n", "\n") + .replace("\r", "\n") + + val escapedValue = normalizedValue + .replace("\\", "\\\\") + .replace("\n", "\\n") + .replace(";", "\\;") + .replace(",", "\\,") + + writeContentLine("$name:$escapedValue") + } } From 4ae4cd2f0547de24a1ce356d1e797b19bf879cc6 Mon Sep 17 00:00:00 2001 From: Robert Ros Date: Wed, 18 Feb 2026 17:28:45 +0100 Subject: [PATCH 2/9] Replace writeContentLine with writeTextProperty where needed to make sure the values are normalized and escaped properly. --- .../kotlin/org/fossify/calendar/helpers/IcsExporter.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt index 8952c1252..d36eeceb9 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt @@ -89,7 +89,7 @@ class IcsExporter(private val context: Context) { val reminder = it out.apply { writeContentLine(BEGIN_ALARM) - writeContentLine("$DESCRIPTION_EXPORT$reminderLabel") + writeTextProperty(DESCRIPTION, reminderLabel) if (reminder.type == REMINDER_NOTIFICATION) { writeContentLine("$ACTION$DISPLAY") } else { @@ -157,10 +157,10 @@ class IcsExporter(private val context: Context) { } writeContentLine("$FOSSIFY_COLOR${event.color}") } - writeContentLine("$CATEGORIES${context.calendarsDB.getCalendarWithId(event.calendarId)?.title}") + writeTextProperty("CATEGORIES", context.calendarsDB.getCalendarWithId(event.calendarId)?.title ?: "") writeContentLine("$LAST_MODIFIED:${Formatter.getExportedTime(event.lastUpdated)}") writeContentLine("$TRANSP${if (event.availability == Events.AVAILABILITY_FREE) TRANSPARENT else OPAQUE}") - event.location.let { if (it.isNotEmpty()) writeContentLine("$LOCATION:$it") } + event.location.let { if (it.isNotEmpty()) writeTextProperty(LOCATION, it) } if (event.getIsAllDay()) { val tz = try { @@ -211,9 +211,9 @@ class IcsExporter(private val context: Context) { } writeContentLine("$FOSSIFY_COLOR${task.color}") } - writeContentLine("$CATEGORIES${context.calendarsDB.getCalendarWithId(task.calendarId)?.title}") + writeTextProperty("CATEGORIES", context.calendarsDB.getCalendarWithId(task.calendarId)?.title ?: "") writeContentLine("$LAST_MODIFIED:${Formatter.getExportedTime(task.lastUpdated)}") - task.location.let { if (it.isNotEmpty()) writeContentLine("$LOCATION:$it") } + task.location.let { if (it.isNotEmpty()) writeTextProperty(LOCATION, it) } if (task.getIsAllDay()) { writeContentLine("$DTSTART;$VALUE=$DATE:${Formatter.getDayCodeFromTS(task.startTS)}") From 229b9de60e6e34cbc89063a8821fe189ffc18af3 Mon Sep 17 00:00:00 2001 From: Robert Ros Date: Wed, 18 Feb 2026 18:10:07 +0100 Subject: [PATCH 3/9] Just use a BufferedOutputStream instead of a BufferedWriter --- .../fossify/calendar/helpers/IcsExporter.kt | 54 ++++++++----------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt index d36eeceb9..13ad2d5c0 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt @@ -12,14 +12,17 @@ import org.fossify.calendar.helpers.IcsExporter.ExportResult.EXPORT_PARTIAL import org.fossify.calendar.models.CalDAVCalendar import org.fossify.calendar.models.Event import org.fossify.commons.extensions.toast -import org.fossify.commons.extensions.writeLn import org.fossify.commons.helpers.ensureBackgroundThread import org.joda.time.DateTimeZone -import java.io.BufferedWriter +import java.io.BufferedOutputStream import java.io.OutputStream -import java.io.OutputStreamWriter +import java.nio.charset.Charset class IcsExporter(private val context: Context) { + companion object { + val DEFAULT_CHARSET: Charset = Charsets.UTF_8 + } + enum class ExportResult { EXPORT_FAIL, EXPORT_OK, EXPORT_PARTIAL } @@ -48,19 +51,7 @@ class IcsExporter(private val context: Context) { context.toast(org.fossify.commons.R.string.exporting) } - object : BufferedWriter(OutputStreamWriter(outputStream, Charsets.UTF_8)) { - val lineSeparator = "\r\n" - - /** - * Writes a line separator. The line separator string is defined by RFC 5545 in 3.1. Content Lines: - * Content Lines are delimited by a line break, which is a CRLF sequence (CR character followed by LF character). - * - * @see RFC 5545 - 3.1. Content Lines - */ - override fun newLine() { - write(lineSeparator) - } - }.use { out -> + BufferedOutputStream(outputStream).use { out -> out.writeContentLine(BEGIN_CALENDAR) out.writeContentLine(CALENDAR_PRODID) out.writeContentLine(CALENDAR_VERSION) @@ -84,10 +75,10 @@ class IcsExporter(private val context: Context) { } } - private fun fillReminders(event: Event, out: BufferedWriter, reminderLabel: String) { + private fun fillReminders(event: Event, outputStream: OutputStream, reminderLabel: String) { event.getReminders().forEach { val reminder = it - out.apply { + outputStream.apply { writeContentLine(BEGIN_ALARM) writeTextProperty(DESCRIPTION, reminderLabel) if (reminder.type == REMINDER_NOTIFICATION) { @@ -108,9 +99,9 @@ class IcsExporter(private val context: Context) { } } - private fun fillIgnoredOccurrences(event: Event, out: BufferedWriter) { + private fun fillIgnoredOccurrences(event: Event, outputStream: OutputStream) { event.repetitionExceptions.forEach { - out.writeContentLine("$EXDATE:$it") + outputStream.writeContentLine("$EXDATE:$it") } } @@ -143,9 +134,9 @@ class IcsExporter(private val context: Context) { } } - private fun writeEvent(writer: BufferedWriter, event: Event) { + private fun writeEvent(outputStream: OutputStream, event: Event) { val calendarColors = context.eventsHelper.getCalendarColors() - with(writer) { + with(outputStream) { writeContentLine(BEGIN_EVENT) event.title.let { if (it.isNotEmpty()) writeTextProperty(SUMMARY, it) } event.importId.let { if (it.isNotEmpty()) writeContentLine("$UID$it") } @@ -189,17 +180,17 @@ class IcsExporter(private val context: Context) { Parser().getRepeatCode(event).let { if (it.isNotEmpty()) writeContentLine("$RRULE$it") } writeTextProperty(DESCRIPTION, event.description) - fillReminders(event, writer, reminderLabel) - fillIgnoredOccurrences(event, writer) + fillReminders(event, outputStream, reminderLabel) + fillIgnoredOccurrences(event, outputStream) eventsExported++ writeContentLine(END_EVENT) } } - private fun writeTask(writer: BufferedWriter, task: Event) { + private fun writeTask(outputStream: OutputStream, task: Event) { val calendarColors = context.eventsHelper.getCalendarColors() - with(writer) { + with(outputStream) { writeContentLine(BEGIN_TASK) task.title.let { if (it.isNotEmpty()) writeTextProperty(SUMMARY, it) } task.importId.let { if (it.isNotEmpty()) writeContentLine("$UID$it") } @@ -228,21 +219,22 @@ class IcsExporter(private val context: Context) { Parser().getRepeatCode(task).let { if (it.isNotEmpty()) writeContentLine("$RRULE$it") } writeTextProperty(DESCRIPTION, task.description) - fillReminders(task, writer, reminderLabel) - fillIgnoredOccurrences(task, writer) + fillReminders(task, outputStream, reminderLabel) + fillIgnoredOccurrences(task, outputStream) eventsExported++ writeContentLine(END_TASK) } } - private fun BufferedWriter.writeContentLine(line: String) { + private fun OutputStream.writeContentLine(line: String) { for (segment in foldContentLine(line)) { - this.writeLn(segment) + this.write(segment.toByteArray(DEFAULT_CHARSET)) + this.write("\r\n".toByteArray(DEFAULT_CHARSET)) } } - private fun BufferedWriter.writeTextProperty(name: String, value: String) { + private fun OutputStream.writeTextProperty(name: String, value: String) { val normalizedValue = value .replace("\r\n", "\n") .replace("\r", "\n") From 40b91ba3a954af2dc38725a523f8126d93223477 Mon Sep 17 00:00:00 2001 From: Robert Ros Date: Wed, 18 Feb 2026 21:05:13 +0100 Subject: [PATCH 4/9] Refactor foldContentLine into a separate class. --- .../fossify/calendar/helpers/IcsExporter.kt | 35 +++--------------- .../calendar/icalendar/ContentLineFolder.kt | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 31 deletions(-) create mode 100644 app/src/main/kotlin/org/fossify/calendar/icalendar/ContentLineFolder.kt diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt index 13ad2d5c0..709238dc5 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt @@ -9,6 +9,7 @@ import org.fossify.calendar.extensions.eventsHelper import org.fossify.calendar.helpers.IcsExporter.ExportResult.EXPORT_FAIL import org.fossify.calendar.helpers.IcsExporter.ExportResult.EXPORT_OK import org.fossify.calendar.helpers.IcsExporter.ExportResult.EXPORT_PARTIAL +import org.fossify.calendar.icalendar.ContentLineFolder import org.fossify.calendar.models.CalDAVCalendar import org.fossify.calendar.models.Event import org.fossify.commons.extensions.toast @@ -27,7 +28,6 @@ class IcsExporter(private val context: Context) { EXPORT_FAIL, EXPORT_OK, EXPORT_PARTIAL } - private val MAX_LINE_LENGTH = 75 private var eventsExported = 0 private var eventsFailed = 0 private var calendars = ArrayList() @@ -105,35 +105,6 @@ class IcsExporter(private val context: Context) { } } - private fun foldContentLine(line: String): Sequence = sequence { - var index = 0 - var isFirstLine = true - - while (index < line.length) { - var end = index + MAX_LINE_LENGTH - // Take the prepended space into account. - if (!isFirstLine) end-- - if (end > line.length) { - end = line.length - } else { - // Avoid splitting surrogate pairs - if (Character.isHighSurrogate(line[end - 1])) { - end-- - } - } - - val substring = line.substring(index, end) - if (isFirstLine) { - yield(substring) - } else { - yield(" $substring") - } - - isFirstLine = false - index = end - } - } - private fun writeEvent(outputStream: OutputStream, event: Event) { val calendarColors = context.eventsHelper.getCalendarColors() with(outputStream) { @@ -227,8 +198,10 @@ class IcsExporter(private val context: Context) { } } + private val contentLineFolder = ContentLineFolder() + private fun OutputStream.writeContentLine(line: String) { - for (segment in foldContentLine(line)) { + for (segment in contentLineFolder.fold(line)) { this.write(segment.toByteArray(DEFAULT_CHARSET)) this.write("\r\n".toByteArray(DEFAULT_CHARSET)) } diff --git a/app/src/main/kotlin/org/fossify/calendar/icalendar/ContentLineFolder.kt b/app/src/main/kotlin/org/fossify/calendar/icalendar/ContentLineFolder.kt new file mode 100644 index 000000000..6d48ed5f0 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/calendar/icalendar/ContentLineFolder.kt @@ -0,0 +1,36 @@ +package org.fossify.calendar.icalendar + +internal class ContentLineFolder { + companion object { + private const val MAX_LINE_LENGTH = 75 + } + + fun fold(line: String): Sequence = sequence { + var index = 0 + var isFirstLine = true + + while (index < line.length) { + var end = index + MAX_LINE_LENGTH + // Take the prepended space into account. + if (!isFirstLine) end-- + if (end > line.length) { + end = line.length + } else { + // Avoid splitting surrogate pairs + if (Character.isHighSurrogate(line[end - 1])) { + end-- + } + } + + val substring = line.substring(index, end) + if (isFirstLine) { + yield(substring) + } else { + yield(" $substring") + } + + isFirstLine = false + index = end + } + } +} From d9bf4668093e54ee8f742f2eb3a8c42b22633bc3 Mon Sep 17 00:00:00 2001 From: Robert Ros Date: Wed, 18 Feb 2026 21:43:44 +0100 Subject: [PATCH 5/9] Refactor ContentLineWriter and ContentLineFolder into separate classes. --- .../fossify/calendar/helpers/IcsExporter.kt | 16 +++----------- .../calendar/icalendar/ContentLineFolder.kt | 6 +++--- .../calendar/icalendar/ContentLineWriter.kt | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 16 deletions(-) create mode 100644 app/src/main/kotlin/org/fossify/calendar/icalendar/ContentLineWriter.kt diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt index 709238dc5..62638f208 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt @@ -9,7 +9,7 @@ import org.fossify.calendar.extensions.eventsHelper import org.fossify.calendar.helpers.IcsExporter.ExportResult.EXPORT_FAIL import org.fossify.calendar.helpers.IcsExporter.ExportResult.EXPORT_OK import org.fossify.calendar.helpers.IcsExporter.ExportResult.EXPORT_PARTIAL -import org.fossify.calendar.icalendar.ContentLineFolder +import org.fossify.calendar.icalendar.ContentLineWriter import org.fossify.calendar.models.CalDAVCalendar import org.fossify.calendar.models.Event import org.fossify.commons.extensions.toast @@ -17,13 +17,8 @@ import org.fossify.commons.helpers.ensureBackgroundThread import org.joda.time.DateTimeZone import java.io.BufferedOutputStream import java.io.OutputStream -import java.nio.charset.Charset class IcsExporter(private val context: Context) { - companion object { - val DEFAULT_CHARSET: Charset = Charsets.UTF_8 - } - enum class ExportResult { EXPORT_FAIL, EXPORT_OK, EXPORT_PARTIAL } @@ -198,14 +193,9 @@ class IcsExporter(private val context: Context) { } } - private val contentLineFolder = ContentLineFolder() + private val contentLineWriter = ContentLineWriter() - private fun OutputStream.writeContentLine(line: String) { - for (segment in contentLineFolder.fold(line)) { - this.write(segment.toByteArray(DEFAULT_CHARSET)) - this.write("\r\n".toByteArray(DEFAULT_CHARSET)) - } - } + private fun OutputStream.writeContentLine(line: String) = contentLineWriter.write(this, line) private fun OutputStream.writeTextProperty(name: String, value: String) { val normalizedValue = value diff --git a/app/src/main/kotlin/org/fossify/calendar/icalendar/ContentLineFolder.kt b/app/src/main/kotlin/org/fossify/calendar/icalendar/ContentLineFolder.kt index 6d48ed5f0..2527a3e9e 100644 --- a/app/src/main/kotlin/org/fossify/calendar/icalendar/ContentLineFolder.kt +++ b/app/src/main/kotlin/org/fossify/calendar/icalendar/ContentLineFolder.kt @@ -1,8 +1,8 @@ package org.fossify.calendar.icalendar -internal class ContentLineFolder { +internal class ContentLineFolder(private val maxLength: Int = DEFAULT_MAX_LENGTH) { companion object { - private const val MAX_LINE_LENGTH = 75 + private const val DEFAULT_MAX_LENGTH = 75 } fun fold(line: String): Sequence = sequence { @@ -10,7 +10,7 @@ internal class ContentLineFolder { var isFirstLine = true while (index < line.length) { - var end = index + MAX_LINE_LENGTH + var end = index + maxLength // Take the prepended space into account. if (!isFirstLine) end-- if (end > line.length) { diff --git a/app/src/main/kotlin/org/fossify/calendar/icalendar/ContentLineWriter.kt b/app/src/main/kotlin/org/fossify/calendar/icalendar/ContentLineWriter.kt new file mode 100644 index 000000000..b4ea92586 --- /dev/null +++ b/app/src/main/kotlin/org/fossify/calendar/icalendar/ContentLineWriter.kt @@ -0,0 +1,21 @@ +package org.fossify.calendar.icalendar + +import java.io.OutputStream +import java.nio.charset.Charset + +internal class ContentLineWriter( + private val charset: Charset = DEFAULT_CHARSET, + private val folder: ContentLineFolder = ContentLineFolder() +) { + companion object { + val DEFAULT_CHARSET: Charset = Charsets.UTF_8 + private const val CRLF = "\r\n" + } + + fun write(outputStream: OutputStream, line: String) { + for (segment in folder.fold(line)) { + outputStream.write(segment.toByteArray(charset)) + outputStream.write(CRLF.toByteArray(charset)) + } + } +} From d1db5f4a2ff48642b5e5829ad3b75f3144d35438 Mon Sep 17 00:00:00 2001 From: Robert Ros Date: Wed, 18 Feb 2026 21:44:57 +0100 Subject: [PATCH 6/9] Reduce the nesting of the fillReminders function --- .../fossify/calendar/helpers/IcsExporter.kt | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt index 62638f208..166ddde02 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt @@ -17,6 +17,7 @@ import org.fossify.commons.helpers.ensureBackgroundThread import org.joda.time.DateTimeZone import java.io.BufferedOutputStream import java.io.OutputStream +import kotlin.math.abs class IcsExporter(private val context: Context) { enum class ExportResult { @@ -71,26 +72,23 @@ class IcsExporter(private val context: Context) { } private fun fillReminders(event: Event, outputStream: OutputStream, reminderLabel: String) { - event.getReminders().forEach { - val reminder = it - outputStream.apply { - writeContentLine(BEGIN_ALARM) - writeTextProperty(DESCRIPTION, reminderLabel) - if (reminder.type == REMINDER_NOTIFICATION) { - writeContentLine("$ACTION$DISPLAY") - } else { - writeContentLine("$ACTION$EMAIL") - val attendee = - calendars.firstOrNull { it.id == event.getCalDAVCalendarId() }?.accountName - if (attendee != null) { - writeContentLine("$ATTENDEE$MAILTO$attendee") - } + event.getReminders().forEach { reminder -> + outputStream.writeContentLine(BEGIN_ALARM) + outputStream.writeTextProperty(DESCRIPTION, reminderLabel) + if (reminder.type == REMINDER_NOTIFICATION) { + outputStream.writeContentLine("$ACTION$DISPLAY") + } else { + outputStream.writeContentLine("$ACTION$EMAIL") + val attendee = + calendars.firstOrNull { it.id == event.getCalDAVCalendarId() }?.accountName + if (attendee != null) { + outputStream.writeContentLine("$ATTENDEE$MAILTO$attendee") } - - val sign = if (reminder.minutes < -1) "" else "-" - writeContentLine("$TRIGGER:$sign${Parser().getDurationCode(Math.abs(reminder.minutes.toLong()))}") - writeContentLine(END_ALARM) } + + val sign = if (reminder.minutes < -1) "" else "-" + outputStream.writeContentLine("$TRIGGER:$sign${Parser().getDurationCode(abs(reminder.minutes.toLong()))}") + outputStream.writeContentLine(END_ALARM) } } From 960a199bf82367e014ef3cf2e1e33f02aef455eb Mon Sep 17 00:00:00 2001 From: Robert Ros Date: Wed, 18 Feb 2026 21:49:29 +0100 Subject: [PATCH 7/9] Reduce the complexity of the writeEvent function --- .../fossify/calendar/helpers/IcsExporter.kt | 90 +++++++++---------- 1 file changed, 44 insertions(+), 46 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt index 166ddde02..4fa010534 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt @@ -98,58 +98,56 @@ class IcsExporter(private val context: Context) { } } - private fun writeEvent(outputStream: OutputStream, event: Event) { + private fun writeEvent(out: OutputStream, event: Event) { val calendarColors = context.eventsHelper.getCalendarColors() - with(outputStream) { - writeContentLine(BEGIN_EVENT) - event.title.let { if (it.isNotEmpty()) writeTextProperty(SUMMARY, it) } - event.importId.let { if (it.isNotEmpty()) writeContentLine("$UID$it") } - writeContentLine("$CATEGORY_COLOR${context.calendarsDB.getCalendarWithId(event.calendarId)?.color}") - if (event.color != 0 && event.color != calendarColors[event.calendarId]) { - val color = CssColors.findClosestCssColor(event.color) - if (color != null) { - writeContentLine("$COLOR${color}") - } - writeContentLine("$FOSSIFY_COLOR${event.color}") + out.writeContentLine(BEGIN_EVENT) + if (event.title.isNotEmpty()) out.writeTextProperty(SUMMARY, event.title) + if (event.importId.isNotEmpty()) out.writeContentLine("$UID${event.importId}") + out.writeContentLine("$CATEGORY_COLOR${context.calendarsDB.getCalendarWithId(event.calendarId)?.color}") + if (event.color != 0 && event.color != calendarColors[event.calendarId]) { + val color = CssColors.findClosestCssColor(event.color) + if (color != null) { + out.writeContentLine("$COLOR${color}") } - writeTextProperty("CATEGORIES", context.calendarsDB.getCalendarWithId(event.calendarId)?.title ?: "") - writeContentLine("$LAST_MODIFIED:${Formatter.getExportedTime(event.lastUpdated)}") - writeContentLine("$TRANSP${if (event.availability == Events.AVAILABILITY_FREE) TRANSPARENT else OPAQUE}") - event.location.let { if (it.isNotEmpty()) writeTextProperty(LOCATION, it) } - - if (event.getIsAllDay()) { - val tz = try { - DateTimeZone.forID(event.timeZone) - } catch (ignored: IllegalArgumentException) { - DateTimeZone.getDefault() - } - writeContentLine("$DTSTART;$VALUE=$DATE:${Formatter.getDayCodeFromTS(event.startTS, tz)}") - writeContentLine( - "$DTEND;$VALUE=$DATE:${ - Formatter.getDayCodeFromTS( - event.endTS + TWELVE_HOURS, - tz - ) - }" - ) - } else { - writeContentLine("$DTSTART:${Formatter.getExportedTime(event.startTS * 1000L)}") - writeContentLine("$DTEND:${Formatter.getExportedTime(event.endTS * 1000L)}") + out.writeContentLine("$FOSSIFY_COLOR${event.color}") + } + out.writeTextProperty("CATEGORIES", context.calendarsDB.getCalendarWithId(event.calendarId)?.title ?: "") + out.writeContentLine("$LAST_MODIFIED:${Formatter.getExportedTime(event.lastUpdated)}") + out.writeContentLine("$TRANSP${if (event.availability == Events.AVAILABILITY_FREE) TRANSPARENT else OPAQUE}") + if (event.location.isNotEmpty()) out.writeTextProperty(LOCATION, event.location) + + if (event.getIsAllDay()) { + val tz = try { + DateTimeZone.forID(event.timeZone) + } catch (ignored: IllegalArgumentException) { + DateTimeZone.getDefault() } - writeContentLine("$MISSING_YEAR${if (event.hasMissingYear()) 1 else 0}") + out.writeContentLine("$DTSTART;$VALUE=$DATE:${Formatter.getDayCodeFromTS(event.startTS, tz)}") + out.writeContentLine( + "$DTEND;$VALUE=$DATE:${ + Formatter.getDayCodeFromTS( + event.endTS + TWELVE_HOURS, + tz + ) + }" + ) + } else { + out.writeContentLine("$DTSTART:${Formatter.getExportedTime(event.startTS * 1000L)}") + out.writeContentLine("$DTEND:${Formatter.getExportedTime(event.endTS * 1000L)}") + } + out.writeContentLine("$MISSING_YEAR${if (event.hasMissingYear()) 1 else 0}") - writeContentLine("$DTSTAMP$exportTime") - writeContentLine("$CLASS:${getAccessLevelStringFromEventAccessLevel(event.accessLevel)}") - writeContentLine("$STATUS${getStatusStringFromEventStatus(event.status)}") - Parser().getRepeatCode(event).let { if (it.isNotEmpty()) writeContentLine("$RRULE$it") } + out.writeContentLine("$DTSTAMP$exportTime") + out.writeContentLine("$CLASS:${getAccessLevelStringFromEventAccessLevel(event.accessLevel)}") + out.writeContentLine("$STATUS${getStatusStringFromEventStatus(event.status)}") + Parser().getRepeatCode(event).let { if (it.isNotEmpty()) out.writeContentLine("$RRULE$it") } - writeTextProperty(DESCRIPTION, event.description) - fillReminders(event, outputStream, reminderLabel) - fillIgnoredOccurrences(event, outputStream) + out.writeTextProperty(DESCRIPTION, event.description) + fillReminders(event, out, reminderLabel) + fillIgnoredOccurrences(event, out) - eventsExported++ - writeContentLine(END_EVENT) - } + eventsExported++ + out.writeContentLine(END_EVENT) } private fun writeTask(outputStream: OutputStream, task: Event) { From da18a1f78d19a0d0715ccfd30b89d3f68d87099a Mon Sep 17 00:00:00 2001 From: Robert Ros Date: Wed, 18 Feb 2026 21:53:23 +0100 Subject: [PATCH 8/9] Reduce the complexity of the writeTask function --- .../fossify/calendar/helpers/IcsExporter.kt | 60 +++++++++---------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt index 4fa010534..1735a2ac8 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt @@ -150,43 +150,41 @@ class IcsExporter(private val context: Context) { out.writeContentLine(END_EVENT) } - private fun writeTask(outputStream: OutputStream, task: Event) { + private fun writeTask(out: OutputStream, task: Event) { val calendarColors = context.eventsHelper.getCalendarColors() - with(outputStream) { - writeContentLine(BEGIN_TASK) - task.title.let { if (it.isNotEmpty()) writeTextProperty(SUMMARY, it) } - task.importId.let { if (it.isNotEmpty()) writeContentLine("$UID$it") } - writeContentLine("$CATEGORY_COLOR${context.calendarsDB.getCalendarWithId(task.calendarId)?.color}") - if (task.color != 0 && task.color != calendarColors[task.calendarId]) { - val color = CssColors.findClosestCssColor(task.color) - if (color != null) { - writeContentLine("$COLOR${color}") - } - writeContentLine("$FOSSIFY_COLOR${task.color}") + out.writeContentLine(BEGIN_TASK) + if (task.title.isNotEmpty()) out.writeTextProperty(SUMMARY, task.title) + if (task.importId.isNotEmpty()) out.writeContentLine("$UID${task.importId}") + out.writeContentLine("$CATEGORY_COLOR${context.calendarsDB.getCalendarWithId(task.calendarId)?.color}") + if (task.color != 0 && task.color != calendarColors[task.calendarId]) { + val color = CssColors.findClosestCssColor(task.color) + if (color != null) { + out.writeContentLine("$COLOR${color}") } - writeTextProperty("CATEGORIES", context.calendarsDB.getCalendarWithId(task.calendarId)?.title ?: "") - writeContentLine("$LAST_MODIFIED:${Formatter.getExportedTime(task.lastUpdated)}") - task.location.let { if (it.isNotEmpty()) writeTextProperty(LOCATION, it) } + out.writeContentLine("$FOSSIFY_COLOR${task.color}") + } + out.writeTextProperty("CATEGORIES", context.calendarsDB.getCalendarWithId(task.calendarId)?.title ?: "") + out.writeContentLine("$LAST_MODIFIED:${Formatter.getExportedTime(task.lastUpdated)}") + if (task.location.isNotEmpty()) out.writeTextProperty(LOCATION, task.location) - if (task.getIsAllDay()) { - writeContentLine("$DTSTART;$VALUE=$DATE:${Formatter.getDayCodeFromTS(task.startTS)}") - } else { - writeContentLine("$DTSTART:${Formatter.getExportedTime(task.startTS * 1000L)}") - } + if (task.getIsAllDay()) { + out.writeContentLine("$DTSTART;$VALUE=$DATE:${Formatter.getDayCodeFromTS(task.startTS)}") + } else { + out.writeContentLine("$DTSTART:${Formatter.getExportedTime(task.startTS * 1000L)}") + } - writeContentLine("$DTSTAMP$exportTime") - if (task.isTaskCompleted()) { - writeContentLine("$STATUS$COMPLETED") - } - Parser().getRepeatCode(task).let { if (it.isNotEmpty()) writeContentLine("$RRULE$it") } + out.writeContentLine("$DTSTAMP$exportTime") + if (task.isTaskCompleted()) { + out.writeContentLine("$STATUS$COMPLETED") + } + Parser().getRepeatCode(task).let { if (it.isNotEmpty()) out.writeContentLine("$RRULE$it") } - writeTextProperty(DESCRIPTION, task.description) - fillReminders(task, outputStream, reminderLabel) - fillIgnoredOccurrences(task, outputStream) + out.writeTextProperty(DESCRIPTION, task.description) + fillReminders(task, out, reminderLabel) + fillIgnoredOccurrences(task, out) - eventsExported++ - writeContentLine(END_TASK) - } + eventsExported++ + out.writeContentLine(END_TASK) } private val contentLineWriter = ContentLineWriter() From c170f758b1090d81bb0ee352a5af2e803b516109 Mon Sep 17 00:00:00 2001 From: Robert Ros Date: Wed, 18 Feb 2026 21:53:47 +0100 Subject: [PATCH 9/9] Rename outputStream to out for readability --- .../fossify/calendar/helpers/IcsExporter.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt index 1735a2ac8..73543d800 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt @@ -71,30 +71,30 @@ class IcsExporter(private val context: Context) { } } - private fun fillReminders(event: Event, outputStream: OutputStream, reminderLabel: String) { + private fun fillReminders(event: Event, out: OutputStream, reminderLabel: String) { event.getReminders().forEach { reminder -> - outputStream.writeContentLine(BEGIN_ALARM) - outputStream.writeTextProperty(DESCRIPTION, reminderLabel) + out.writeContentLine(BEGIN_ALARM) + out.writeTextProperty(DESCRIPTION, reminderLabel) if (reminder.type == REMINDER_NOTIFICATION) { - outputStream.writeContentLine("$ACTION$DISPLAY") + out.writeContentLine("$ACTION$DISPLAY") } else { - outputStream.writeContentLine("$ACTION$EMAIL") + out.writeContentLine("$ACTION$EMAIL") val attendee = calendars.firstOrNull { it.id == event.getCalDAVCalendarId() }?.accountName if (attendee != null) { - outputStream.writeContentLine("$ATTENDEE$MAILTO$attendee") + out.writeContentLine("$ATTENDEE$MAILTO$attendee") } } val sign = if (reminder.minutes < -1) "" else "-" - outputStream.writeContentLine("$TRIGGER:$sign${Parser().getDurationCode(abs(reminder.minutes.toLong()))}") - outputStream.writeContentLine(END_ALARM) + out.writeContentLine("$TRIGGER:$sign${Parser().getDurationCode(abs(reminder.minutes.toLong()))}") + out.writeContentLine(END_ALARM) } } - private fun fillIgnoredOccurrences(event: Event, outputStream: OutputStream) { + private fun fillIgnoredOccurrences(event: Event, out: OutputStream) { event.repetitionExceptions.forEach { - outputStream.writeContentLine("$EXDATE:$it") + out.writeContentLine("$EXDATE:$it") } }