Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ androidx-test-runner = "1.7.0"
dokka = "2.1.0"
ezvcard = "0.12.1"
guava = "33.5.0-android"
# noinspection GradleDependency
# noinspection NewerVersionAvailable,GradleDependency
ical4j = "3.2.19" # final version; update to 4.x will require much work
junit = "4.13.2"
kotlin = "2.2.20"
mockk = "1.14.6"
roboelectric = "4.16"
slf4j = "2.0.17"
spotbugs = "4.9.8"

[libraries]
android-desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "android-desugar" }
Expand All @@ -31,6 +32,7 @@ mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
roboelectric = { module = "org.robolectric:robolectric", version.ref = "roboelectric" }
slf4j-jdk = { module = "org.slf4j:slf4j-jdk14", version.ref = "slf4j" }
spotbugs-annotations = { module = "com.github.spotbugs:spotbugs-annotations", version.ref = "spotbugs" }

[plugins]
android-library = { id = "com.android.library", version.ref = "agp" }
Expand Down
3 changes: 3 additions & 0 deletions lib/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ dependencies {
// synctools.test package also provide test rules
implementation(libs.androidx.test.rules)

// Useful annotations
api(libs.spotbugs.annotations)

// instrumented tests
androidTestImplementation(libs.androidx.test.rules)
androidTestImplementation(libs.androidx.test.runner)
Expand Down
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As there's no Android dependency, it should be possible to make this test a unit test (faster and easier to run than an instrumentation test).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/*
* This file is part of bitfireAT/synctools which is released under GPLv3.
* Copyright © All Contributors. See the LICENSE and AUTHOR files in the root directory for details.
* SPDX-License-Identifier: GPL-3.0-or-later
*/

package at.bitfire.synctools.icalendar.validation

import org.junit.Test
import java.io.Reader
import java.util.UUID

class ICalPreprocessorInstrumentedTest {

class VCalendarReaderGenerator(val eventCount: Int = Int.MAX_VALUE) : Reader() {
private var stage = 0 // 0 = header, 1 = events, 2 = footer, 3 = done
private var eventIdx = 0
private var current: String? = null
private var pos = 0

override fun reset() {
stage = 0
eventIdx = 0
current = null
pos = 0
}

override fun read(cbuf: CharArray, off: Int, len: Int): Int {
var charsRead = 0
while (charsRead < len) {
if (current == null || pos >= current!!.length) {
current = when (stage) {
0 -> {
stage = 1
"""
BEGIN:VCALENDAR
PRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN
VERSION:2.0
""".trimIndent() + "\n"
}
1 -> {
if (eventIdx < eventCount) {
val event = """
BEGIN:VEVENT
DTSTAMP:19960704T120000Z
UID:${UUID.randomUUID()}
ORGANIZER:mailto:[email protected]
DTSTART:19960918T143000Z
DTEND:19960920T220000Z
STATUS:CONFIRMED
CATEGORIES:CONFERENCE
SUMMARY:Event $eventIdx
DESCRIPTION:Event $eventIdx description
END:VEVENT
""".trimIndent() + "\n"
eventIdx++
event
} else {
stage = 2
null
}
}
2 -> {
stage = 3
"END:VCALENDAR\n"
}
else -> return if (charsRead == 0) -1 else charsRead
}
pos = 0
if (current == null) continue // move to next stage
}
val charsLeft = current!!.length - pos
val toRead = minOf(len - charsRead, charsLeft)
current!!.toCharArray(pos, pos + toRead).copyInto(cbuf, off + charsRead)
pos += toRead
charsRead += toRead
}
return charsRead
}

override fun close() {
// No resources to release
current = null
}
}

@Test
fun testParse_SuperLargeFiles() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this test (class) do?

As I understand it, this test basically tests whether the input is put into the memory as one chunk (which would fail with OOM) or whether it's processed in streaming mode so that it doesn't load too big files into the memory.

So I think there are two things that can be tested:

  1. Whether the preprocessor does anything when its result Reader is not consumed / being read. I think this is what testParse_SuperLargeFiles currently does: it verifies that preprocessStream doesn't actually load the input as long as the resulting Reader is not consumed.

However, when the input is not read at all, why then generate an iCalendar at all? We could as well pass an infinite stream of the same character.

  1. Whether the resulting Reader returns a correct result when consumed. I think we can also use a fake input string and then check with mockk that applyPreprocessors is called. We can return another fake value and verify that it's what we want.

This is currently not done by the tests. I noticed it because if we pass Int.MAX_VALUE events to it, it should take quite a time to process 2147483647 events – however the test returns immediately.


And everything for the two cases that

  1. the input stream supports reset(), and
  2. that it doesn't support reset().

Copy link
Member Author

@ArnyminerZ ArnyminerZ Oct 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this test (class) do?

As I understand it, this test basically tests whether the input is put into the memory as one chunk (which would fail with OOM) or whether it's processed in streaming mode so that it doesn't load too big files into the memory.

Yes, and it's purposefully placed in the instrumented test class so that it's run in an emulator, which causes the OOM. Otherwise we are not actually overflowing the emulator but our computer.

That's why there's the instrumented suffix, and actually, also exists ICalPreprocessorTest, which is what does the actual testing of the class, because, as you've said below, it doesn't require anything from Android.

This is currently not done by the tests. I noticed it because if we pass Int.MAX_VALUE events to it, it should take quite a time to process 2147483647 events – however the test returns immediately.

Mmmmh yeah, good point

val preprocessor = ICalPreprocessor()
val reader = VCalendarReaderGenerator()
preprocessor.preprocessStream(reader)
// no exception called
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@

package at.bitfire.synctools.icalendar.validation

import androidx.annotation.VisibleForTesting

/**
* Fixes durations with day offsets with the 'T' prefix.
* See also https://github.com/bitfireAT/ical4android/issues/77
*/
class FixInvalidDayOffsetPreprocessor : StreamPreprocessor() {
class FixInvalidDayOffsetPreprocessor : StreamPreprocessor {

override fun regexpForProblem() = Regex(
@VisibleForTesting
val regexpForProblem = Regex(
Comment on lines +17 to +18
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be enough to use internal? I usually combine @VisibleForTesting with internal so that tests can access the field, but other packages can't.

// Examples:
// TRIGGER:-P2DT
// TRIGGER:-PT2D
Expand All @@ -21,11 +24,11 @@ class FixInvalidDayOffsetPreprocessor : StreamPreprocessor() {
setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)
)

override fun fixString(original: String): String {
var iCal: String = original
override fun fixString(lines: String): String {
var iCal: String = lines

// Find all instances matching the defined expression
val found = regexpForProblem().findAll(iCal).toList()
val found = regexpForProblem.findAll(iCal).toList()

// ... and repair them. Use reversed order so that already replaced occurrences don't interfere with the following matches.
for (match in found.reversed()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,28 +6,28 @@

package at.bitfire.synctools.icalendar.validation

import androidx.annotation.VisibleForTesting
import java.util.logging.Level
import java.util.logging.Logger


/**
* Some servers modify UTC offsets in TZOFFSET(FROM,TO) like "+005730" to an invalid "+5730".
*
* Rewrites values of all TZOFFSETFROM and TZOFFSETTO properties which match [TZOFFSET_REGEXP]
* Rewrites values of all TZOFFSETFROM and TZOFFSETTO properties which match [regexpForProblem]
* so that an hour value of 00 is inserted.
*/
class FixInvalidUtcOffsetPreprocessor: StreamPreprocessor() {
class FixInvalidUtcOffsetPreprocessor: StreamPreprocessor {

private val logger
get() = Logger.getLogger(javaClass.name)

private val TZOFFSET_REGEXP = Regex("^(TZOFFSET(FROM|TO):[+\\-]?)((18|19|[2-6]\\d)\\d\\d)$",
@VisibleForTesting
val regexpForProblem = Regex("^(TZOFFSET(FROM|TO):[+\\-]?)((18|19|[2-6]\\d)\\d\\d)$",
setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE))

override fun regexpForProblem() = TZOFFSET_REGEXP

override fun fixString(original: String) =
original.replace(TZOFFSET_REGEXP) {
override fun fixString(lines: String) =
lines.replace(regexpForProblem) {
logger.log(Level.FINE, "Applying Synology WebDAV fix to invalid utc-offset", it.value)
"${it.groupValues[1]}00${it.groupValues[3]}"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,21 @@
package at.bitfire.synctools.icalendar.validation

import androidx.annotation.VisibleForTesting
import com.google.common.io.CharSource
import com.google.errorprone.annotations.MustBeClosed
import net.fortuna.ical4j.model.Calendar
import net.fortuna.ical4j.model.Property
import net.fortuna.ical4j.transform.rfc5545.CreatedPropertyRule
import net.fortuna.ical4j.transform.rfc5545.DateListPropertyRule
import net.fortuna.ical4j.transform.rfc5545.DatePropertyRule
import net.fortuna.ical4j.transform.rfc5545.Rfc5545PropertyRule
import java.io.BufferedReader
import java.io.IOException
import java.io.Reader
import java.io.StringReader
import java.util.logging.Level
import java.util.logging.Logger
import javax.annotation.WillCloseWhenClosed

/**
* Applies some rules to increase compatibility of parsed (incoming) iCalendars:
Expand All @@ -27,6 +33,9 @@ import java.util.logging.Logger
*/
class ICalPreprocessor {

private val logger
get() = Logger.getLogger(javaClass.name)

private val propertyRules = arrayOf(
CreatedPropertyRule(), // make sure CREATED is UTC

Expand All @@ -40,18 +49,64 @@ class ICalPreprocessor {
FixInvalidDayOffsetPreprocessor() // fix things like DURATION:PT2D
)

/**
* Applies [streamPreprocessors] to some given [lines] by calling `fixString()` repeatedly on each of them.
* @param lines original iCalendar object as string. This may not contain the full iCalendar,
* but only a part of it.
* @return The repaired iCalendar object as string.
*/
@VisibleForTesting
fun applyPreprocessors(lines: String): String {
var newString = lines
for (preprocessor in streamPreprocessors)
newString = preprocessor.fixString(newString)
return newString
}

/**
* Applies [streamPreprocessors] to a given [Reader] that reads an iCalendar object
* in order to repair some things that must be fixed before parsing.
*
* @param original original iCalendar object
* @return the potentially repaired iCalendar object
* The original reader content is processed in chunks of [chunkSize] lines to avoid loading
* the whole content into memory at once. If the given [Reader] does not support `reset()`,
* the whole content will be loaded into memory anyway.
*
* Closing the returned [Reader] will also close the [original] reader if needed.
*
* @param original original iCalendar object. Will be closed after processing.
* @param chunkSize number of lines to process in one chunk. Default is `1000`.
* @return A reader that emits the potentially repaired iCalendar object.
* The returned [Reader] must be closed by the caller.
*/
fun preprocessStream(original: Reader): Reader {
var reader = original
for (preprocessor in streamPreprocessors)
reader = preprocessor.preprocess(reader)
return reader
@MustBeClosed
fun preprocessStream(@WillCloseWhenClosed original: Reader, chunkSize: Int = 1_000): Reader {
val resetSupported = try {
original.reset()
true
Comment on lines +83 to +85
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just noticed that we don't need support for reset() when we use line-based preprocessors, right?

So we can just remove the resetSupported and the if, if I understand it correctly.

} catch(_: IOException) {
// reset is not supported. String will be loaded into memory completely
false
}

if (resetSupported) {
val chunkedFixedLines = BufferedReader(original)
.lineSequence()
.chunked(chunkSize)
// iCalendar uses CRLF: https://www.rfc-editor.org/rfc/rfc5545#section-3.1
// but we only use \n because in tests line breaks are LF-only.
// Since CRLF already contains LF, this is not an issue.
.map { chunk ->
val fixed = applyPreprocessors(chunk.joinToString("\n"))
CharSource.wrap(fixed)
}
.asIterable()
// we don't close 'original' here because CharSource.concat() will read from it lazily
return CharSource.concat(chunkedFixedLines).openStream()
} else {
// The reader doesn't support reset, so we need to load the whole content into memory
logger.warning("Reader does not support reset(). Reading complete iCalendar into memory.")
return StringReader(applyPreprocessors(original.use { it.readText() }))
}
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,51 +6,14 @@

package at.bitfire.synctools.icalendar.validation

import java.io.IOException
import java.io.Reader
import java.io.StringReader
import java.util.Scanner

abstract class StreamPreprocessor {

abstract fun regexpForProblem(): Regex?
interface StreamPreprocessor {

/**
* Fixes an iCalendar string.
*
* @param original The complete iCalendar string
* @return The complete iCalendar string, but fixed
* @param lines The iCalendar lines to fix. Those may be the full iCalendar file, or just a part of it.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should explicitly mention that lines must only contain complete lines. Because when we have to add another StreamPreprocessor at some day, we won't know anymore what we did now, but we still need to know whether our input (lines) can contain parts of lines or only complete lines.

* @return The fixed version of [lines].
*/
abstract fun fixString(original: String): String

fun preprocess(reader: Reader): Reader {
var result: String? = null

val resetSupported = try {
reader.reset()
true
} catch(_: IOException) {
false
}

if (resetSupported) {
val regex = regexpForProblem()
// reset is supported, no need to copy the whole stream to another String (unless we have to fix the TZOFFSET)
if (regex == null || Scanner(reader).findWithinHorizon(regex.toPattern(), 0) != null) {
reader.reset()
result = fixString(reader.readText())
}
} else
// reset not supported, always generate a new String that will be returned
result = fixString(reader.readText())

if (result != null)
// modified or reset not supported, return new stream
return StringReader(result)

// not modified, return original iCalendar
reader.reset()
return reader
}
fun fixString(lines: String): String

}
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,14 @@ class FixInvalidDayOffsetPreprocessorTest {

@Test
fun test_RegexpForProblem_DayOffsetTo_Invalid() {
val regex = processor.regexpForProblem()
val regex = processor.regexpForProblem
assertTrue(regex.matches("DURATION:PT2D"))
assertTrue(regex.matches("TRIGGER:PT1D"))
}

@Test
fun test_RegexpForProblem_DayOffsetTo_Valid() {
val regex = processor.regexpForProblem()
val regex = processor.regexpForProblem
assertFalse(regex.matches("DURATION:-PT12H"))
assertFalse(regex.matches("TRIGGER:-PT15M"))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ class FixInvalidUtcOffsetPreprocessorTest {

@Test
fun test_RegexpForProblem_TzOffsetTo_Invalid() {
val regex = processor.regexpForProblem()
val regex = processor.regexpForProblem
assertTrue(regex.matches("TZOFFSETTO:+5730"))
}

@Test
fun test_RegexpForProblem_TzOffsetTo_Valid() {
val regex = processor.regexpForProblem()
val regex = processor.regexpForProblem
assertFalse(regex.matches("TZOFFSETTO:+005730"))
}

Expand Down
Loading