diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 5a48d567fac..8931402f98e 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -62,6 +62,22 @@ limitations under the License.
---
+## Howard Hinnant — Date Algorithms (Public Domain)
+
+**Source:** https://howardhinnant.github.io/date_algorithms.html
+**License:** Public Domain
+**Copyright:** None; public domain dedication by Howard Hinnant
+
+### Scope
+
+The Sentry Java SDK includes adapted civil date conversion algorithms from Howard Hinnant's date algorithms for UTC ISO 8601 timestamp parsing and formatting. The code resides in `io.sentry.vendor.SentryIso8601Utils`.
+
+```
+Consider these donated to the public domain.
+```
+
+---
+
## Android Open Source Project — Base64 (Apache 2.0)
**Source:** https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/Base64.java
diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api
index 45268a9a894..df043e92e1c 100644
--- a/sentry/api/sentry.api
+++ b/sentry/api/sentry.api
@@ -8076,6 +8076,11 @@ public class io/sentry/vendor/Base64 {
public static fun encodeToString ([BIII)Ljava/lang/String;
}
+public final class io/sentry/vendor/SentryIso8601Utils {
+ public static fun formatTimestamp (J)Ljava/lang/String;
+ public static fun parseTimestamp (Ljava/lang/String;)J
+}
+
public class io/sentry/vendor/gson/internal/bind/util/ISO8601Utils {
public static final field TIMEZONE_UTC Ljava/util/TimeZone;
public fun ()V
diff --git a/sentry/src/main/java/io/sentry/Breadcrumb.java b/sentry/src/main/java/io/sentry/Breadcrumb.java
index d122d1459bf..c45b15b4711 100644
--- a/sentry/src/main/java/io/sentry/Breadcrumb.java
+++ b/sentry/src/main/java/io/sentry/Breadcrumb.java
@@ -823,7 +823,12 @@ public static final class JsonKeys {
public void serialize(final @NotNull ObjectWriter writer, final @NotNull ILogger logger)
throws IOException {
writer.beginObject();
- writer.name(JsonKeys.TIMESTAMP).value(logger, getTimestamp());
+ writer
+ .name(JsonKeys.TIMESTAMP)
+ .value(
+ timestampMs != null
+ ? DateUtils.getTimestampFromMillis(timestampMs)
+ : DateUtils.getTimestamp(getTimestamp()));
if (message != null) {
writer.name(JsonKeys.MESSAGE).value(message);
}
diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java
index 31a8dcd76ea..3fae5cf8264 100644
--- a/sentry/src/main/java/io/sentry/DateUtils.java
+++ b/sentry/src/main/java/io/sentry/DateUtils.java
@@ -2,11 +2,9 @@
import static io.sentry.vendor.gson.internal.bind.util.ISO8601Utils.TIMEZONE_UTC;
-import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils;
+import io.sentry.vendor.SentryIso8601Utils;
import java.math.BigDecimal;
import java.math.RoundingMode;
-import java.text.ParseException;
-import java.text.ParsePosition;
import java.util.Calendar;
import java.util.Date;
import org.jetbrains.annotations.ApiStatus;
@@ -15,6 +13,7 @@
/** Utilities to deal with dates */
@ApiStatus.Internal
+@SuppressWarnings("JavaUtilDate")
public final class DateUtils {
private DateUtils() {}
@@ -39,8 +38,8 @@ private DateUtils() {}
public static @NotNull Date getDateTime(final @NotNull String timestamp)
throws IllegalArgumentException {
try {
- return ISO8601Utils.parse(timestamp, new ParsePosition(0));
- } catch (ParseException e) {
+ return getDateTime(SentryIso8601Utils.parseTimestamp(timestamp));
+ } catch (IllegalArgumentException e) {
throw new IllegalArgumentException("timestamp is not ISO format " + timestamp);
}
}
@@ -51,7 +50,6 @@ private DateUtils() {}
* @param timestamp millis eg 1581410911.988 (1581410911 seconds and 988 millis)
* @return the UTC Date
*/
- @SuppressWarnings("JdkObsolete")
public static @NotNull Date getDateTimeWithMillisPrecision(final @NotNull String timestamp)
throws IllegalArgumentException {
try {
@@ -69,7 +67,17 @@ private DateUtils() {}
* @return the UTC/ISO 8601 timestamp
*/
public static @NotNull String getTimestamp(final @NotNull Date date) {
- return ISO8601Utils.format(date, true);
+ return getTimestampFromMillis(date.getTime());
+ }
+
+ /**
+ * Get the UTC/ISO 8601 timestamp from millis.
+ *
+ * @param millis the UTC millis from the epoch
+ * @return the UTC/ISO 8601 timestamp
+ */
+ static @NotNull String getTimestampFromMillis(final long millis) {
+ return SentryIso8601Utils.formatTimestamp(millis);
}
/**
diff --git a/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java
new file mode 100644
index 00000000000..66fdc34e80b
--- /dev/null
+++ b/sentry/src/main/java/io/sentry/vendor/SentryIso8601Utils.java
@@ -0,0 +1,282 @@
+// Civil date conversion algorithms adapted from Howard Hinnant's date algorithms.
+// Placed in the public domain by Howard Hinnant.
+// https://howardhinnant.github.io/date_algorithms.html
+
+package io.sentry.vendor;
+
+import org.jetbrains.annotations.ApiStatus;
+import org.jetbrains.annotations.NotNull;
+
+@ApiStatus.Internal
+public final class SentryIso8601Utils {
+
+ private static final long MILLIS_PER_SECOND = 1000L;
+ 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 int DAYS_0000_TO_1970 = 719468;
+
+ private SentryIso8601Utils() {}
+
+ public static long parseTimestamp(final @NotNull String timestamp) {
+ final int length = timestamp.length();
+ int offset = 0;
+
+ final int year = parseInt(timestamp, offset, offset += 4);
+ if (checkOffset(timestamp, offset, '-')) {
+ offset++;
+ }
+
+ final int month = parseInt(timestamp, offset, offset += 2);
+ if (checkOffset(timestamp, offset, '-')) {
+ offset++;
+ }
+
+ 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);
+ }
+ offset++;
+
+ final int hour = parseInt(timestamp, offset, offset += 2);
+ if (checkOffset(timestamp, offset, ':')) {
+ offset++;
+ }
+
+ final int minute = parseInt(timestamp, offset, offset += 2);
+ if (checkOffset(timestamp, offset, ':')) {
+ offset++;
+ }
+
+ int second = 0;
+ int millisecond = 0;
+ if (length > offset) {
+ final char c = timestamp.charAt(offset);
+ if (c != 'Z' && c != '+' && c != '-') {
+ second = parseInt(timestamp, offset, offset += 2);
+ if (second > 59 && second < 63) {
+ second = 59;
+ }
+ if (checkOffset(timestamp, offset, '.')) {
+ offset++;
+ final int endOffset = indexOfNonDigit(timestamp, offset);
+ if (endOffset == offset) {
+ throw new IllegalArgumentException("Missing millisecond digits");
+ }
+ final int parseEndOffset = Math.min(endOffset, offset + 3);
+ final int fraction = parseInt(timestamp, offset, parseEndOffset);
+ switch (parseEndOffset - offset) {
+ case 1:
+ millisecond = fraction * 100;
+ break;
+ case 2:
+ millisecond = fraction * 10;
+ break;
+ default:
+ millisecond = fraction;
+ break;
+ }
+ offset = endOffset;
+ }
+ }
+ }
+ validateTime(hour, minute, second, millisecond);
+
+ if (length <= offset) {
+ throw new IllegalArgumentException("No time zone indicator");
+ }
+
+ final int timezoneOffsetMillis;
+ final char timezoneIndicator = timestamp.charAt(offset);
+ if (timezoneIndicator == 'Z') {
+ timezoneOffsetMillis = 0;
+ offset++;
+ } else if (timezoneIndicator == '+' || timezoneIndicator == '-') {
+ final int sign = timezoneIndicator == '+' ? 1 : -1;
+ offset++;
+ final int timezoneHour = parseInt(timestamp, offset, offset += 2);
+ int timezoneMinute = 0;
+ if (checkOffset(timestamp, offset, ':')) {
+ offset++;
+ }
+ if (length >= offset + 2) {
+ timezoneMinute = parseInt(timestamp, offset, offset += 2);
+ }
+ validateTimezone(timezoneHour, timezoneMinute);
+ timezoneOffsetMillis =
+ sign * (int) (timezoneHour * MILLIS_PER_HOUR + timezoneMinute * MILLIS_PER_MINUTE);
+ } else {
+ throw new IllegalArgumentException("Invalid time zone indicator");
+ }
+
+ if (offset != length) {
+ throw new IllegalArgumentException("Invalid trailing characters");
+ }
+
+ return epochMillis(year, month, day, hour, minute, second, millisecond, timezoneOffsetMillis);
+ }
+
+ public static @NotNull String formatTimestamp(final long millis) {
+ final long epochDay = Math.floorDiv(millis, MILLIS_PER_DAY);
+ int millisOfDay = (int) Math.floorMod(millis, MILLIS_PER_DAY);
+
+ final int[] yearMonthDay = epochDayToYearMonthDay(epochDay);
+ final int hour = millisOfDay / (int) MILLIS_PER_HOUR;
+ millisOfDay -= hour * (int) MILLIS_PER_HOUR;
+ final int minute = millisOfDay / (int) MILLIS_PER_MINUTE;
+ millisOfDay -= minute * (int) MILLIS_PER_MINUTE;
+ final int second = millisOfDay / (int) MILLIS_PER_SECOND;
+ final int millisecond = millisOfDay - second * (int) MILLIS_PER_SECOND;
+
+ final StringBuilder timestamp = new StringBuilder("yyyy-MM-ddThh:mm:ss.sssZ".length());
+ padInt(timestamp, yearMonthDay[0], "yyyy".length());
+ timestamp.append('-');
+ padInt(timestamp, yearMonthDay[1], "MM".length());
+ timestamp.append('-');
+ padInt(timestamp, yearMonthDay[2], "dd".length());
+ timestamp.append('T');
+ padInt(timestamp, hour, "hh".length());
+ timestamp.append(':');
+ padInt(timestamp, minute, "mm".length());
+ timestamp.append(':');
+ padInt(timestamp, second, "ss".length());
+ timestamp.append('.');
+ padInt(timestamp, millisecond, "sss".length());
+ timestamp.append('Z');
+ return timestamp.toString();
+ }
+
+ private static long epochMillis(
+ final int year,
+ final int month,
+ final int day,
+ final int hour,
+ final int minute,
+ final int second,
+ final int millisecond,
+ final int timezoneOffsetMillis) {
+ return daysFromYearMonthDay(year, month, day) * MILLIS_PER_DAY
+ + hour * MILLIS_PER_HOUR
+ + minute * MILLIS_PER_MINUTE
+ + second * MILLIS_PER_SECOND
+ + millisecond
+ - timezoneOffsetMillis;
+ }
+
+ private static long daysFromYearMonthDay(int year, final int month, final int day) {
+ year -= month <= 2 ? 1 : 0;
+ final long era = Math.floorDiv(year, 400);
+ final int yearOfEra = (int) (year - era * 400);
+ final int dayOfYear = (153 * (month + (month > 2 ? -3 : 9)) + 2) / 5 + day - 1;
+ final int dayOfEra = yearOfEra * 365 + yearOfEra / 4 - yearOfEra / 100 + dayOfYear;
+ return era * 146097 + dayOfEra - DAYS_0000_TO_1970;
+ }
+
+ private static int[] epochDayToYearMonthDay(long epochDay) {
+ epochDay += DAYS_0000_TO_1970;
+ final long era = Math.floorDiv(epochDay, 146097);
+ final int dayOfEra = (int) (epochDay - era * 146097);
+ final int yearOfEra = (dayOfEra - dayOfEra / 1460 + dayOfEra / 36524 - dayOfEra / 146096) / 365;
+ final int year = (int) (yearOfEra + era * 400);
+ final int dayOfYear = dayOfEra - (365 * yearOfEra + yearOfEra / 4 - yearOfEra / 100);
+ final int monthPrime = (5 * dayOfYear + 2) / 153;
+ final int day = dayOfYear - (153 * monthPrime + 2) / 5 + 1;
+ final int month = monthPrime < 10 ? monthPrime + 3 : monthPrime - 9;
+ return new int[] {year + (month <= 2 ? 1 : 0), month, day};
+ }
+
+ 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");
+ }
+ }
+
+ private static void validateTime(
+ final int hour, final int minute, final int second, final int millisecond) {
+ if (hour < 0
+ || hour > 23
+ || minute < 0
+ || minute > 59
+ || second < 0
+ || second > 59
+ || millisecond < 0
+ || millisecond > 999) {
+ throw new IllegalArgumentException("Invalid time");
+ }
+ }
+
+ private static void validateTimezone(final int hour, final int minute) {
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
+ throw new IllegalArgumentException("Invalid time zone");
+ }
+ }
+
+ private static int daysInMonth(final int year, final int month) {
+ switch (month) {
+ case 2:
+ return isLeapYear(year) ? 29 : 28;
+ case 4:
+ case 6:
+ case 9:
+ case 11:
+ return 30;
+ default:
+ return 31;
+ }
+ }
+
+ private static boolean isLeapYear(final int year) {
+ return (year % 4 == 0) && (year % 100 != 0 || year % 400 == 0);
+ }
+
+ private static boolean checkOffset(
+ final @NotNull String value, final int offset, final char expected) {
+ return offset < value.length() && value.charAt(offset) == expected;
+ }
+
+ private static int parseInt(
+ final @NotNull String value, final int beginIndex, final int endIndex) {
+ if (beginIndex < 0 || endIndex > value.length() || beginIndex >= endIndex) {
+ throw new NumberFormatException(value);
+ }
+
+ int result = 0;
+ for (int i = beginIndex; i < endIndex; i++) {
+ final char c = value.charAt(i);
+ if (c < '0' || c > '9') {
+ throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex));
+ }
+ result = result * 10 + c - '0';
+ }
+ return result;
+ }
+
+ private static void padInt(
+ final @NotNull StringBuilder buffer, final int value, final int length) {
+ if (value < 0) {
+ buffer.append('-');
+ padInt(buffer, -value, length);
+ return;
+ }
+ final String strValue = Integer.toString(value);
+ for (int i = length - strValue.length(); i > 0; i--) {
+ buffer.append('0');
+ }
+ buffer.append(strValue);
+ }
+
+ private static int indexOfNonDigit(final @NotNull String string, final int offset) {
+ for (int i = offset; i < string.length(); i++) {
+ final char c = string.charAt(i);
+ if (c < '0' || c > '9') {
+ return i;
+ }
+ }
+ return string.length();
+ }
+}
diff --git a/sentry/src/test/java/io/sentry/DateUtilsTest.kt b/sentry/src/test/java/io/sentry/DateUtilsTest.kt
index 9e234b50c1b..0746b1a1b5f 100644
--- a/sentry/src/test/java/io/sentry/DateUtilsTest.kt
+++ b/sentry/src/test/java/io/sentry/DateUtilsTest.kt
@@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter
import java.util.Date
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -34,6 +35,54 @@ class DateUtilsTest {
assertEquals("2020-03-27T08:52:58.000Z", timestamp)
}
+ @Test
+ fun `When ISO date has offset`() {
+ val input =
+ mapOf(
+ "2020-03-27T10:52:58.015+02:00" to "2020-03-27T08:52:58.015Z",
+ "2020-03-27T10:52:58.015+0200" to "2020-03-27T08:52:58.015Z",
+ "2020-03-27T10:52:58.015+02" to "2020-03-27T08:52:58.015Z",
+ "2020-03-27T05:52:58.015-03:00" to "2020-03-27T08:52:58.015Z",
+ )
+
+ input.forEach {
+ val timestamp = convertDate(DateUtils.getDateTime(it.key)).format(isoFormat)
+
+ assertEquals(it.value, timestamp)
+ }
+ }
+
+ @Test
+ fun `When ISO date uses compact separators`() {
+ val date = DateUtils.getDateTime("20200327T085258.015Z")
+
+ val utcDate = convertDate(date)
+ val timestamp = utcDate.format(isoFormat)
+
+ assertEquals("2020-03-27T08:52:58.015Z", timestamp)
+ }
+
+ @Test
+ fun `When ISO date has short fraction`() {
+ val input =
+ mapOf(
+ "2020-03-27T08:52:58.1Z" to "2020-03-27T08:52:58.100Z",
+ "2020-03-27T08:52:58.12Z" to "2020-03-27T08:52:58.120Z",
+ "2020-03-27T08:52:58.123456Z" to "2020-03-27T08:52:58.123Z",
+ )
+
+ input.forEach {
+ val timestamp = convertDate(DateUtils.getDateTime(it.key)).format(isoFormat)
+
+ assertEquals(it.value, timestamp)
+ }
+ }
+
+ @Test
+ fun `When ISO date is invalid`() {
+ assertFailsWith { DateUtils.getDateTime("2020-02-30T08:52:58Z") }
+ }
+
@Test
fun `Converts from Date to ISO 8601 and back to Date`() {
val currentDate = DateUtils.getCurrentDateTime()
@@ -78,6 +127,21 @@ class DateUtilsTest {
assertTrue { utcCurrentDate.minusSeconds(1).isBefore(utcDate) }
}
+ @Test
+ fun `Formats millis to ISO 8601 timestamp`() {
+ val input =
+ mapOf(
+ Instant.parse("1970-01-01T00:00:00.000Z").toEpochMilli() to "1970-01-01T00:00:00.000Z",
+ Instant.parse("1969-12-31T23:59:59.999Z").toEpochMilli() to "1969-12-31T23:59:59.999Z",
+ Instant.parse("2000-02-29T12:34:56.789Z").toEpochMilli() to "2000-02-29T12:34:56.789Z",
+ Instant.parse("1900-03-01T00:00:00.000Z").toEpochMilli() to "1900-03-01T00:00:00.000Z",
+ Instant.parse("2100-03-01T00:00:00.000Z").toEpochMilli() to "2100-03-01T00:00:00.000Z",
+ Instant.parse("2400-02-29T23:59:59.999Z").toEpochMilli() to "2400-02-29T23:59:59.999Z",
+ )
+
+ input.forEach { assertEquals(it.value, DateUtils.getTimestampFromMillis(it.key)) }
+ }
+
@Test
fun `Millis formats to Date`() {
val millis = 1591533492L * 1000L + 631
@@ -86,6 +150,7 @@ class DateUtilsTest {
val utcActual = convertDate(actual)
val timestamp = utcActual.format(isoFormat)
+ assertEquals(millis, actual.time)
assertEquals("2020-06-07T12:38:12.631Z", timestamp)
}