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..73543d800 100644 --- a/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt +++ b/app/src/main/kotlin/org/fossify/calendar/helpers/IcsExporter.kt @@ -9,22 +9,21 @@ 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.ContentLineWriter 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 kotlin.math.abs class IcsExporter(private val context: Context) { enum class ExportResult { EXPORT_FAIL, EXPORT_OK, EXPORT_PARTIAL } - private val MAX_LINE_LENGTH = 75 private var eventsExported = 0 private var eventsFailed = 0 private var calendars = ArrayList() @@ -48,22 +47,10 @@ 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 -> - out.writeLn(BEGIN_CALENDAR) - out.writeLn(CALENDAR_PRODID) - out.writeLn(CALENDAR_VERSION) + BufferedOutputStream(outputStream).use { out -> + out.writeContentLine(BEGIN_CALENDAR) + out.writeContentLine(CALENDAR_PRODID) + out.writeContentLine(CALENDAR_VERSION) for (event in events) { if (event.isTask()) { writeTask(out, event) @@ -71,7 +58,7 @@ class IcsExporter(private val context: Context) { writeEvent(out, event) } } - out.writeLn(END_CALENDAR) + out.writeContentLine(END_CALENDAR) } callback( @@ -84,153 +71,137 @@ class IcsExporter(private val context: Context) { } } - private fun fillReminders(event: Event, out: BufferedWriter, reminderLabel: String) { - event.getReminders().forEach { - val reminder = it - out.apply { - writeLn(BEGIN_ALARM) - writeLn("$DESCRIPTION_EXPORT$reminderLabel") - if (reminder.type == REMINDER_NOTIFICATION) { - writeLn("$ACTION$DISPLAY") - } else { - writeLn("$ACTION$EMAIL") - val attendee = - calendars.firstOrNull { it.id == event.getCalDAVCalendarId() }?.accountName - if (attendee != null) { - writeLn("$ATTENDEE$MAILTO$attendee") - } + private fun fillReminders(event: Event, out: OutputStream, reminderLabel: String) { + event.getReminders().forEach { reminder -> + out.writeContentLine(BEGIN_ALARM) + out.writeTextProperty(DESCRIPTION, reminderLabel) + if (reminder.type == REMINDER_NOTIFICATION) { + out.writeContentLine("$ACTION$DISPLAY") + } else { + out.writeContentLine("$ACTION$EMAIL") + val attendee = + calendars.firstOrNull { it.id == event.getCalDAVCalendarId() }?.accountName + if (attendee != null) { + out.writeContentLine("$ATTENDEE$MAILTO$attendee") } - - val sign = if (reminder.minutes < -1) "" else "-" - writeLn("$TRIGGER:$sign${Parser().getDurationCode(Math.abs(reminder.minutes.toLong()))}") - writeLn(END_ALARM) } + + val sign = if (reminder.minutes < -1) "" else "-" + out.writeContentLine("$TRIGGER:$sign${Parser().getDurationCode(abs(reminder.minutes.toLong()))}") + out.writeContentLine(END_ALARM) } } - private fun fillIgnoredOccurrences(event: Event, out: BufferedWriter) { + private fun fillIgnoredOccurrences(event: Event, out: OutputStream) { event.repetitionExceptions.forEach { - out.writeLn("$EXDATE:$it") + out.writeContentLine("$EXDATE:$it") } } - private fun fillDescription(description: String, out: BufferedWriter) { - var index = 0 - var isFirstLine = true - - while (index < description.length) { - var end = index + MAX_LINE_LENGTH - if (end > description.length) { - end = description.length - } else { - // Avoid splitting surrogate pairs - if (Character.isHighSurrogate(description[end - 1])) { - end-- - } + private fun writeEvent(out: OutputStream, event: Event) { + val calendarColors = context.eventsHelper.getCalendarColors() + 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}") } - - val substring = description.substring(index, end) - if (isFirstLine) { - out.writeLn("$DESCRIPTION_EXPORT$substring") - } else { - out.writeLn(" $substring") + 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() } - - isFirstLine = false - index = end + 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}") + + 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") } + + out.writeTextProperty(DESCRIPTION, event.description) + fillReminders(event, out, reminderLabel) + fillIgnoredOccurrences(event, out) + + eventsExported++ + out.writeContentLine(END_EVENT) } - private fun writeEvent(writer: BufferedWriter, event: Event) { + private fun writeTask(out: OutputStream, task: 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}") - if (event.color != 0 && event.color != calendarColors[event.calendarId]) { - val color = CssColors.findClosestCssColor(event.color) - if (color != null) { - writeLn("$COLOR${color}") - } - writeLn("$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") } - - if (event.getIsAllDay()) { - val tz = try { - DateTimeZone.forID(event.timeZone) - } catch (ignored: IllegalArgumentException) { - DateTimeZone.getDefault() - } - writeLn("$DTSTART;$VALUE=$DATE:${Formatter.getDayCodeFromTS(event.startTS, tz)}") - writeLn( - "$DTEND;$VALUE=$DATE:${ - Formatter.getDayCodeFromTS( - event.endTS + TWELVE_HOURS, - tz - ) - }" - ) - } else { - writeLn("$DTSTART:${Formatter.getExportedTime(event.startTS * 1000L)}") - writeLn("$DTEND:${Formatter.getExportedTime(event.endTS * 1000L)}") + 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}") } - writeLn("$MISSING_YEAR${if (event.hasMissingYear()) 1 else 0}") + 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()) { + out.writeContentLine("$DTSTART;$VALUE=$DATE:${Formatter.getDayCodeFromTS(task.startTS)}") + } else { + out.writeContentLine("$DTSTART:${Formatter.getExportedTime(task.startTS * 1000L)}") + } - writeLn("$DTSTAMP$exportTime") - writeLn("$CLASS:${getAccessLevelStringFromEventAccessLevel(event.accessLevel)}") - writeLn("$STATUS${getStatusStringFromEventStatus(event.status)}") - Parser().getRepeatCode(event).let { if (it.isNotEmpty()) writeLn("$RRULE$it") } + out.writeContentLine("$DTSTAMP$exportTime") + if (task.isTaskCompleted()) { + out.writeContentLine("$STATUS$COMPLETED") + } + Parser().getRepeatCode(task).let { if (it.isNotEmpty()) out.writeContentLine("$RRULE$it") } - fillDescription(event.description.replace("\n", "\\n"), writer) - fillReminders(event, writer, reminderLabel) - fillIgnoredOccurrences(event, writer) + out.writeTextProperty(DESCRIPTION, task.description) + fillReminders(task, out, reminderLabel) + fillIgnoredOccurrences(task, out) - eventsExported++ - writeLn(END_EVENT) - } + eventsExported++ + out.writeContentLine(END_TASK) } - 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}") - if (task.color != 0 && task.color != calendarColors[task.calendarId]) { - val color = CssColors.findClosestCssColor(task.color) - if (color != null) { - writeLn("$COLOR${color}") - } - writeLn("$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") } + private val contentLineWriter = ContentLineWriter() - if (task.getIsAllDay()) { - writeLn("$DTSTART;$VALUE=$DATE:${Formatter.getDayCodeFromTS(task.startTS)}") - } else { - writeLn("$DTSTART:${Formatter.getExportedTime(task.startTS * 1000L)}") - } + private fun OutputStream.writeContentLine(line: String) = contentLineWriter.write(this, line) - writeLn("$DTSTAMP$exportTime") - if (task.isTaskCompleted()) { - writeLn("$STATUS$COMPLETED") - } - Parser().getRepeatCode(task).let { if (it.isNotEmpty()) writeLn("$RRULE$it") } + private fun OutputStream.writeTextProperty(name: String, value: String) { + val normalizedValue = value + .replace("\r\n", "\n") + .replace("\r", "\n") - fillDescription(task.description.replace("\n", "\\n"), writer) - fillReminders(task, writer, reminderLabel) - fillIgnoredOccurrences(task, writer) + val escapedValue = normalizedValue + .replace("\\", "\\\\") + .replace("\n", "\\n") + .replace(";", "\\;") + .replace(",", "\\,") - eventsExported++ - writeLn(END_TASK) - } + writeContentLine("$name:$escapedValue") } } 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..2527a3e9e --- /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(private val maxLength: Int = DEFAULT_MAX_LENGTH) { + companion object { + private const val DEFAULT_MAX_LENGTH = 75 + } + + fun fold(line: String): Sequence = sequence { + var index = 0 + var isFirstLine = true + + while (index < line.length) { + var end = index + maxLength + // 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 + } + } +} 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)) + } + } +}