Skip to content
Draft
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
73 changes: 70 additions & 3 deletions sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

package io.sentry.vendor;

import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.SimpleTimeZone;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;

Expand All @@ -14,6 +17,7 @@ public final class SentryIso8601Utils {
private static final long MILLIS_PER_MINUTE = 60L * MILLIS_PER_SECOND;
private static final long MILLIS_PER_HOUR = 60L * MILLIS_PER_MINUTE;
private static final long MILLIS_PER_DAY = 24L * MILLIS_PER_HOUR;
private static final long GREGORIAN_CUTOVER_MILLIS = -12219292800000L;
private static final int DAYS_0000_TO_1970 = 719468;

private SentryIso8601Utils() {}
Expand All @@ -33,14 +37,14 @@ public static long parseTimestamp(final @NotNull String timestamp) {
}

final int day = parseInt(timestamp, offset, offset += 2);
validateDate(year, month, day);

if (!checkOffset(timestamp, offset, 'T')) {
if (offset != length) {
throw new IllegalArgumentException("Invalid date separator");
}
return epochMillis(year, month, day, 0, 0, 0, 0, 0);
return dateOnlyEpochMillis(year, month, day);
}
validateDate(year, month, day);
offset++;

final int hour = parseInt(timestamp, offset, offset += 2);
Expand Down Expand Up @@ -92,10 +96,12 @@ public static long parseTimestamp(final @NotNull String timestamp) {
}

final int timezoneOffsetMillis;
final boolean allowTrailingCharacters;
final char timezoneIndicator = timestamp.charAt(offset);
if (timezoneIndicator == 'Z') {
timezoneOffsetMillis = 0;
offset++;
allowTrailingCharacters = true;
} else if (timezoneIndicator == '+' || timezoneIndicator == '-') {
final int sign = timezoneIndicator == '+' ? 1 : -1;
offset++;
Expand All @@ -110,18 +116,28 @@ public static long parseTimestamp(final @NotNull String timestamp) {
validateTimezone(timezoneHour, timezoneMinute);
timezoneOffsetMillis =
sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE);
allowTrailingCharacters = false;
} else {
throw new IllegalArgumentException("Invalid time zone indicator");
}

if (offset != length) {
if (!allowTrailingCharacters && offset != length) {
throw new IllegalArgumentException("Invalid trailing characters");
}

if (isBeforeGregorianCutover(year, month, day)) {
return epochMillisWithCalendar(
year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis);
}

return epochMillis(year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis);
}

public static @NotNull String formatTimestamp(final long millis) {
if (millis < GREGORIAN_CUTOVER_MILLIS) {
return formatTimestampWithCalendar(millis);
}

final long epochDay = Math.floorDiv(millis, MILLIS_PER_DAY);
int millisOfDay = (int) Math.floorMod(millis, MILLIS_PER_DAY);

Expand Down Expand Up @@ -151,6 +167,53 @@ public static long parseTimestamp(final @NotNull String timestamp) {
return timestamp.toString();
}

private static long dateOnlyEpochMillis(final int year, final int month, final int day) {
return new GregorianCalendar(year, month - 1, day).getTimeInMillis();
}

private static long epochMillisWithCalendar(
final int year,
final int month,
final int day,
final int hour,
final int minute,
final int second,
final int millisecond,
final int timezoneOffsetMillis) {
final GregorianCalendar calendar = new GregorianCalendar(new SimpleTimeZone(timezoneOffsetMillis, "GMT"));
calendar.setLenient(false);
calendar.set(Calendar.YEAR, year);
calendar.set(Calendar.MONTH, month - 1);
calendar.set(Calendar.DAY_OF_MONTH, day);
calendar.set(Calendar.HOUR_OF_DAY, hour);
calendar.set(Calendar.MINUTE, minute);
calendar.set(Calendar.SECOND, second);
calendar.set(Calendar.MILLISECOND, millisecond);
return calendar.getTimeInMillis();
}

private static @NotNull String formatTimestampWithCalendar(final long millis) {
final GregorianCalendar calendar = new GregorianCalendar(new SimpleTimeZone(0, "UTC"));
calendar.setTimeInMillis(millis);

final StringBuilder timestamp = new StringBuilder("yyyy-MM-ddThh:mm:ss.sssZ".length());
padInt(timestamp, calendar.get(Calendar.YEAR), "yyyy".length());
timestamp.append('-');
padInt(timestamp, calendar.get(Calendar.MONTH) + 1, "MM".length());
timestamp.append('-');
padInt(timestamp, calendar.get(Calendar.DAY_OF_MONTH), "dd".length());
timestamp.append('T');
padInt(timestamp, calendar.get(Calendar.HOUR_OF_DAY), "hh".length());
timestamp.append(':');
padInt(timestamp, calendar.get(Calendar.MINUTE), "mm".length());
timestamp.append(':');
padInt(timestamp, calendar.get(Calendar.SECOND), "ss".length());
timestamp.append('.');
padInt(timestamp, calendar.get(Calendar.MILLISECOND), "sss".length());
timestamp.append('Z');
return timestamp.toString();
}

private static long epochMillis(
final int year,
final int month,
Expand Down Expand Up @@ -190,6 +253,10 @@ private static int[] epochDayToYearMonthDay(long epochDay) {
return new int[] {year + (month <= 2 ? 1 : 0), month, day};
}

private static boolean isBeforeGregorianCutover(final int year, final int month, final int day) {
return year < 1582 || (year == 1582 && (month < 10 || (month == 10 && day < 15)));
}

private static void validateDate(final int year, final int month, final int day) {
if (year < 1 || month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month)) {
throw new IllegalArgumentException("Invalid date");
Expand Down
108 changes: 108 additions & 0 deletions sentry/src/test/java/io/sentry/DateUtilsTest.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package io.sentry

import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils
import java.text.ParsePosition
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Date
import java.util.TimeZone
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
Expand Down Expand Up @@ -142,6 +145,101 @@ class DateUtilsTest {
input.forEach { assertEquals(it.value, DateUtils.getTimestampFromMillis(it.key)) }
}

@Test
fun `Fast timestamp formatter matches previous ISO8601 formatter`() {
val input =
listOf(
"1582-10-04T00:00:00.000Z",
"1582-10-15T00:00:00.000Z",
"1900-03-01T00:00:00.000Z",
"1969-12-31T23:59:59.999Z",
"1970-01-01T00:00:00.000Z",
"1999-12-31T23:59:59.999Z",
"2000-02-29T12:34:56.789Z",
"2020-03-27T08:52:58.015Z",
"2024-02-29T23:59:59.001Z",
"2100-03-01T00:00:00.000Z",
"2400-02-29T23:59:59.999Z",
)

input
.map { ISO8601Utils.parse(it, ParsePosition(0)).time }
.forEach {
assertEquals(
ISO8601Utils.format(Date(it), true),
DateUtils.getTimestampFromMillis(it),
"millis=$it",
)
}
}

@Test
fun `Fast timestamp parser matches previous ISO8601 parser`() {
val input =
listOf(
"2020-03-27T08:52Z",
"2020-03-27T08:52:58Z",
"2020-03-27T08:52:58.015Z",
"20200327T085258.015Z",
"2020-03-27T10:52:58.015+02:00",
"2020-03-27T10:52:58.015+0200",
"2020-03-27T10:52:58.015+02",
"2020-03-27T05:52:58.015-03:00",
"2020-03-27T05:22:58.015-0330",
"2020-03-27T08:52:58.1Z",
"2020-03-27T08:52:58.12Z",
"2020-03-27T08:52:58.123456Z",
"2020-03-27T08:52:58Ztrailing",
"2016-12-31T23:59:60Z",
"1582-10-04T00:00:00.000Z",
"1582-10-15T00:00:00.000Z",
"1900-03-01T00:00:00.000Z",
"2000-02-29T12:34:56.789Z",
"2100-03-01T00:00:00.000Z",
)

input.forEach {
assertEquals(
ISO8601Utils.parse(it, ParsePosition(0)).time,
DateUtils.getDateTime(it).time,
"timestamp=$it",
)
}
}

@Test
fun `Fast timestamp parser matches previous ISO8601 parser for date-only values`() {
withDefaultTimeZone("America/Los_Angeles") {
val input = listOf("2020-03-27", "20200327", "2020-02-30")

input.forEach {
assertEquals(
ISO8601Utils.parse(it, ParsePosition(0)).time,
DateUtils.getDateTime(it).time,
"timestamp=$it",
)
}
}
}

@Test
fun `Fast timestamp parser rejects date-time without timezone like previous ISO8601 parser`() {
val input = listOf("2020-03-27T08:52", "2020-03-27T08:52:58", "2020-03-27T08:52:58.015")

input.forEach {
assertFailsWith<Exception>("timestamp=$it") { ISO8601Utils.parse(it, ParsePosition(0)) }
assertFailsWith<IllegalArgumentException>("timestamp=$it") { DateUtils.getDateTime(it) }
}
}

@Test
fun `Fast timestamp parser rejects Gregorian cutover gap like previous ISO8601 parser`() {
val timestamp = "1582-10-10T00:00:00.000Z"

assertFailsWith<Exception> { ISO8601Utils.parse(timestamp, ParsePosition(0)) }
assertFailsWith<IllegalArgumentException> { DateUtils.getDateTime(timestamp) }
}

@Test
fun `Millis formats to Date`() {
val millis = 1591533492L * 1000L + 631
Expand Down Expand Up @@ -185,6 +283,16 @@ class DateUtilsTest {
private fun convertDate(date: Date): LocalDateTime =
Instant.ofEpochMilli(date.time).atZone(utcTimeZone).toLocalDateTime()

private fun withDefaultTimeZone(timeZoneId: String, block: () -> Unit) {
val previousTimeZone = TimeZone.getDefault()
try {
TimeZone.setDefault(TimeZone.getTimeZone(timeZoneId))
block()
} finally {
TimeZone.setDefault(previousTimeZone)
}
}

private fun assertClose(expected: Double, actual: Double?) {
assertNotNull(actual)
val diff = Math.abs(expected - actual)
Expand Down
Loading