diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
index 5a48d567fac..43273fda67b 100644
--- a/THIRD_PARTY_NOTICES.md
+++ b/THIRD_PARTY_NOTICES.md
@@ -34,34 +34,6 @@ limitations under the License.
---
-## FasterXML Jackson — ISO8601Utils (Apache 2.0)
-
-**Source:** https://github.com/FasterXML/jackson-databind
-**License:** Apache License 2.0
-**Copyright:** Copyright (C) 2007-, Tatu Saloranta
-
-### Scope
-
-The Sentry Java SDK includes an adapted version of `ISO8601Utils` from the Jackson Databind library for ISO 8601 date/time parsing and formatting. The code resides in `io.sentry.vendor.gson.internal.bind.util.ISO8601Utils`.
-
-```
-Copyright (C) 2007-, Tatu Saloranta
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-```
-
----
-
## 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 13dfd6b9b39..748aa699513 100644
--- a/sentry/api/sentry.api
+++ b/sentry/api/sentry.api
@@ -7998,15 +7998,6 @@ public class io/sentry/vendor/Base64 {
public static fun encodeToString ([BIII)Ljava/lang/String;
}
-public class io/sentry/vendor/gson/internal/bind/util/ISO8601Utils {
- public static final field TIMEZONE_UTC Ljava/util/TimeZone;
- public fun ()V
- public static fun format (Ljava/util/Date;)Ljava/lang/String;
- public static fun format (Ljava/util/Date;Z)Ljava/lang/String;
- public static fun format (Ljava/util/Date;ZLjava/util/TimeZone;)Ljava/lang/String;
- public static fun parse (Ljava/lang/String;Ljava/text/ParsePosition;)Ljava/util/Date;
-}
-
public class io/sentry/vendor/gson/stream/JsonReader : java/io/Closeable {
public fun (Ljava/io/Reader;)V
public fun beginArray ()V
diff --git a/sentry/build.gradle.kts b/sentry/build.gradle.kts
index 25e700995b4..cc85969b4a3 100644
--- a/sentry/build.gradle.kts
+++ b/sentry/build.gradle.kts
@@ -51,8 +51,9 @@ tasks.jacocoTestReport {
animalsniffer {
ignore =
listOf(
- // We manually check on Android if it's available (API 26+).
- "java.time.Instant"
+ // java.time is only available on Android API 26+. We check availability at runtime
+ // and fall back to a legacy implementation for older devices.
+ "java.time.*"
)
}
diff --git a/sentry/src/main/java/io/sentry/DateUtils.java b/sentry/src/main/java/io/sentry/DateUtils.java
index 31a8dcd76ea..df1c64f4006 100644
--- a/sentry/src/main/java/io/sentry/DateUtils.java
+++ b/sentry/src/main/java/io/sentry/DateUtils.java
@@ -1,22 +1,36 @@
package io.sentry;
-import static io.sentry.vendor.gson.internal.bind.util.ISO8601Utils.TIMEZONE_UTC;
-
-import io.sentry.vendor.gson.internal.bind.util.ISO8601Utils;
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 java.util.GregorianCalendar;
+import java.util.Locale;
+import java.util.TimeZone;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.VisibleForTesting;
/** Utilities to deal with dates */
@ApiStatus.Internal
public final class DateUtils {
+ private static final TimeZone TIMEZONE_UTC = TimeZone.getTimeZone("UTC");
+
+ @VisibleForTesting static final boolean HAS_JAVA_TIME;
+
+ static {
+ boolean available;
+ try {
+ Class.forName("java.time.Instant");
+ available = true;
+ } catch (ClassNotFoundException e) {
+ available = false;
+ }
+ HAS_JAVA_TIME = available;
+ }
+
private DateUtils() {}
/**
@@ -39,9 +53,14 @@ private DateUtils() {}
public static @NotNull Date getDateTime(final @NotNull String timestamp)
throws IllegalArgumentException {
try {
- return ISO8601Utils.parse(timestamp, new ParsePosition(0));
- } catch (ParseException e) {
- throw new IllegalArgumentException("timestamp is not ISO format " + timestamp);
+ if (HAS_JAVA_TIME) {
+ return Iso8601JavaTime.parse(timestamp);
+ }
+ return Iso8601Legacy.parse(timestamp);
+ } catch (IllegalArgumentException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new IllegalArgumentException("timestamp is not ISO format " + timestamp, e);
}
}
@@ -68,8 +87,12 @@ private DateUtils() {}
* @param date the UTC Date
* @return the UTC/ISO 8601 timestamp
*/
+ @SuppressWarnings("JavaUtilDate")
public static @NotNull String getTimestamp(final @NotNull Date date) {
- return ISO8601Utils.format(date, true);
+ if (HAS_JAVA_TIME) {
+ return Iso8601JavaTime.format(date);
+ }
+ return Iso8601Legacy.format(date);
}
/**
@@ -169,4 +192,189 @@ public static long secondsToNanos(final @NotNull long seconds) {
public static @NotNull BigDecimal doubleToBigDecimal(final @NotNull Double value) {
return BigDecimal.valueOf(value).setScale(6, RoundingMode.DOWN);
}
+
+ // region java.time-based ISO 8601 (JVM and Android API 26+)
+
+ @SuppressWarnings("NewApi")
+ static final class Iso8601JavaTime {
+ private static final java.time.format.DateTimeFormatter FORMATTER =
+ java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
+ .withZone(java.time.ZoneOffset.UTC);
+
+ @SuppressWarnings("JavaUtilDate")
+ static @NotNull String format(final @NotNull Date date) {
+ return FORMATTER.format(java.time.Instant.ofEpochMilli(date.getTime()));
+ }
+
+ @SuppressWarnings("JavaUtilDate")
+ static @NotNull Date parse(final @NotNull String timestamp) {
+ try {
+ java.time.OffsetDateTime odt = java.time.OffsetDateTime.parse(timestamp);
+ return new Date(odt.toInstant().toEpochMilli());
+ } catch (java.time.format.DateTimeParseException e) {
+ try {
+ java.time.LocalDate localDate = java.time.LocalDate.parse(timestamp);
+ return new Date(
+ localDate.atStartOfDay(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli());
+ } catch (java.time.format.DateTimeParseException e2) {
+ throw new IllegalArgumentException("timestamp is not ISO format " + timestamp, e);
+ }
+ }
+ }
+ }
+
+ // endregion
+
+ // region Legacy ISO 8601 fallback (Android API < 26 without desugaring)
+
+ @SuppressWarnings({"MagicConstant", "JdkObsolete"})
+ static final class Iso8601Legacy {
+ static @NotNull String format(final @NotNull Date date) {
+ Calendar calendar = new GregorianCalendar(TIMEZONE_UTC, Locale.US);
+ calendar.setTime(date);
+ StringBuilder sb = new StringBuilder(24);
+ padInt(sb, calendar.get(Calendar.YEAR), 4);
+ sb.append('-');
+ padInt(sb, calendar.get(Calendar.MONTH) + 1, 2);
+ sb.append('-');
+ padInt(sb, calendar.get(Calendar.DAY_OF_MONTH), 2);
+ sb.append('T');
+ padInt(sb, calendar.get(Calendar.HOUR_OF_DAY), 2);
+ sb.append(':');
+ padInt(sb, calendar.get(Calendar.MINUTE), 2);
+ sb.append(':');
+ padInt(sb, calendar.get(Calendar.SECOND), 2);
+ sb.append('.');
+ padInt(sb, calendar.get(Calendar.MILLISECOND), 3);
+ sb.append('Z');
+ return sb.toString();
+ }
+
+ static @NotNull Date parse(final @NotNull String date) {
+ int offset = 0;
+ int year = parseInt(date, offset, offset += 4);
+ if (checkOffset(date, offset, '-')) offset++;
+ int month = parseInt(date, offset, offset += 2);
+ if (checkOffset(date, offset, '-')) offset++;
+ int day = parseInt(date, offset, offset += 2);
+
+ int hour = 0;
+ int minutes = 0;
+ int seconds = 0;
+ int milliseconds = 0;
+
+ if (date.length() <= offset) {
+ return new GregorianCalendar(year, month - 1, day).getTime();
+ }
+
+ if (checkOffset(date, offset, 'T')) {
+ offset++;
+ hour = parseInt(date, offset, offset += 2);
+ if (checkOffset(date, offset, ':')) offset++;
+ minutes = parseInt(date, offset, offset += 2);
+ if (checkOffset(date, offset, ':')) offset++;
+
+ if (offset < date.length()) {
+ char c = date.charAt(offset);
+ if (c != 'Z' && c != '+' && c != '-') {
+ seconds = parseInt(date, offset, offset += 2);
+ if (seconds > 59 && seconds < 63) seconds = 59;
+ if (checkOffset(date, offset, '.')) {
+ offset++;
+ int endOffset = offset;
+ while (endOffset < date.length() && Character.isDigit(date.charAt(endOffset))) {
+ endOffset++;
+ }
+ int parseEnd = Math.min(endOffset, offset + 3);
+ int fraction = parseInt(date, offset, parseEnd);
+ switch (parseEnd - offset) {
+ case 2:
+ milliseconds = fraction * 10;
+ break;
+ case 1:
+ milliseconds = fraction * 100;
+ break;
+ default:
+ milliseconds = fraction;
+ }
+ offset = endOffset;
+ }
+ }
+ }
+ }
+
+ if (date.length() <= offset) {
+ throw new IllegalArgumentException("No time zone indicator");
+ }
+
+ TimeZone timezone;
+ char tzIndicator = date.charAt(offset);
+ if (tzIndicator == 'Z') {
+ timezone = TIMEZONE_UTC;
+ } else if (tzIndicator == '+' || tzIndicator == '-') {
+ String tzOffset = date.substring(offset);
+ if (tzOffset.length() < 5) tzOffset = tzOffset + "00";
+ if ("+0000".equals(tzOffset) || "+00:00".equals(tzOffset)) {
+ timezone = TIMEZONE_UTC;
+ } else {
+ timezone = TimeZone.getTimeZone("GMT" + tzOffset);
+ }
+ } else {
+ throw new IllegalArgumentException("Invalid time zone indicator '" + tzIndicator + "'");
+ }
+
+ Calendar calendar = new GregorianCalendar(timezone);
+ 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, minutes);
+ calendar.set(Calendar.SECOND, seconds);
+ calendar.set(Calendar.MILLISECOND, milliseconds);
+
+ return calendar.getTime();
+ }
+
+ private static boolean checkOffset(String value, int offset, char expected) {
+ return offset < value.length() && value.charAt(offset) == expected;
+ }
+
+ private static int parseInt(String value, int beginIndex, int endIndex) {
+ if (beginIndex < 0 || endIndex > value.length() || beginIndex > endIndex) {
+ throw new NumberFormatException(value);
+ }
+ int i = beginIndex;
+ int result = 0;
+ int digit;
+ if (i < endIndex) {
+ digit = Character.digit(value.charAt(i++), 10);
+ if (digit < 0) {
+ throw new NumberFormatException(
+ "Invalid number: " + value.substring(beginIndex, endIndex));
+ }
+ result = -digit;
+ }
+ while (i < endIndex) {
+ digit = Character.digit(value.charAt(i++), 10);
+ if (digit < 0) {
+ throw new NumberFormatException(
+ "Invalid number: " + value.substring(beginIndex, endIndex));
+ }
+ result *= 10;
+ result -= digit;
+ }
+ return -result;
+ }
+
+ private static void padInt(StringBuilder buffer, int value, int length) {
+ String strValue = Integer.toString(value);
+ for (int i = length - strValue.length(); i > 0; i--) {
+ buffer.append('0');
+ }
+ buffer.append(strValue);
+ }
+ }
+
+ // endregion
}
diff --git a/sentry/src/main/java/io/sentry/vendor/gson/internal/bind/util/ISO8601Utils.java b/sentry/src/main/java/io/sentry/vendor/gson/internal/bind/util/ISO8601Utils.java
deleted file mode 100644
index 50fe378a899..00000000000
--- a/sentry/src/main/java/io/sentry/vendor/gson/internal/bind/util/ISO8601Utils.java
+++ /dev/null
@@ -1,378 +0,0 @@
-/*
- * Adapted from https://github.com/FasterXML/jackson-databind/blob/c1e92435c6942386394a2a7733065bb047773107/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java
- *
- * Copyright (C) 2007-, Tatu Saloranta
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.sentry.vendor.gson.internal.bind.util;
-
-import org.jetbrains.annotations.ApiStatus;
-
-// Source: https://github.com/google/gson
-// Tag: gson-parent-2.8.9
-// Commit Hash: 6a368d89da37917be7714c3072b8378f4120110a
-// Changes: @ApiStatus.Internal, SuppressWarnings
-
-import java.text.ParseException;
-import java.text.ParsePosition;
-import java.util.*;
-
-/**
- * Utilities methods for manipulating dates in iso8601 format. This is much much faster and GC friendly than using SimpleDateFormat so
- * highly suitable if you (un)serialize lots of date objects.
- *
- * Supported parse format: [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:]mm]]
- *
- * @see this specification
- */
-@SuppressWarnings({"TryWithIdenticalCatches", "UnusedAssignment", "MagicConstant"}) // Ignore warnings to preserve original code.
-@ApiStatus.Internal
-public class ISO8601Utils
-{
- /**
- * ID to represent the 'UTC' string, default timezone since Jackson 2.7
- *
- * @since 2.7
- */
- private static final String UTC_ID = "UTC";
- /**
- * The UTC timezone, prefetched to avoid more lookups.
- *
- * @since 2.7
- */
- public static final TimeZone TIMEZONE_UTC = TimeZone.getTimeZone(UTC_ID);
-
- /*
- /**********************************************************
- /* Formatting
- /**********************************************************
- */
-
- /**
- * Format a date into 'yyyy-MM-ddThh:mm:ssZ' (default timezone, no milliseconds precision)
- *
- * @param date the date to format
- * @return the date formatted as 'yyyy-MM-ddThh:mm:ssZ'
- */
- public static String format(Date date) {
- return format(date, false, TIMEZONE_UTC);
- }
-
- /**
- * Format a date into 'yyyy-MM-ddThh:mm:ss[.sss]Z' (GMT timezone)
- *
- * @param date the date to format
- * @param millis true to include millis precision otherwise false
- * @return the date formatted as 'yyyy-MM-ddThh:mm:ss[.sss]Z'
- */
- public static String format(Date date, boolean millis) {
- return format(date, millis, TIMEZONE_UTC);
- }
-
- /**
- * Format date into yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm]
- *
- * @param date the date to format
- * @param millis true to include millis precision otherwise false
- * @param tz timezone to use for the formatting (UTC will produce 'Z')
- * @return the date formatted as yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm]
- */
- public static String format(Date date, boolean millis, TimeZone tz) {
- Calendar calendar = new GregorianCalendar(tz, Locale.US);
- calendar.setTime(date);
-
- // estimate capacity of buffer as close as we can (yeah, that's pedantic ;)
- int capacity = "yyyy-MM-ddThh:mm:ss".length();
- capacity += millis ? ".sss".length() : 0;
- capacity += tz.getRawOffset() == 0 ? "Z".length() : "+hh:mm".length();
- StringBuilder formatted = new StringBuilder(capacity);
-
- padInt(formatted, calendar.get(Calendar.YEAR), "yyyy".length());
- formatted.append('-');
- padInt(formatted, calendar.get(Calendar.MONTH) + 1, "MM".length());
- formatted.append('-');
- padInt(formatted, calendar.get(Calendar.DAY_OF_MONTH), "dd".length());
- formatted.append('T');
- padInt(formatted, calendar.get(Calendar.HOUR_OF_DAY), "hh".length());
- formatted.append(':');
- padInt(formatted, calendar.get(Calendar.MINUTE), "mm".length());
- formatted.append(':');
- padInt(formatted, calendar.get(Calendar.SECOND), "ss".length());
- if (millis) {
- formatted.append('.');
- padInt(formatted, calendar.get(Calendar.MILLISECOND), "sss".length());
- }
-
- int offset = tz.getOffset(calendar.getTimeInMillis());
- if (offset != 0) {
- int hours = Math.abs((offset / (60 * 1000)) / 60);
- int minutes = Math.abs((offset / (60 * 1000)) % 60);
- formatted.append(offset < 0 ? '-' : '+');
- padInt(formatted, hours, "hh".length());
- formatted.append(':');
- padInt(formatted, minutes, "mm".length());
- } else {
- formatted.append('Z');
- }
-
- return formatted.toString();
- }
-
- /*
- /**********************************************************
- /* Parsing
- /**********************************************************
- */
-
- /**
- * Parse a date from ISO-8601 formatted string. It expects a format
- * [yyyy-MM-dd|yyyyMMdd][T(hh:mm[:ss[.sss]]|hhmm[ss[.sss]])]?[Z|[+-]hh[:mm]]]
- *
- * @param date ISO string to parse in the appropriate format.
- * @param pos The position to start parsing from, updated to where parsing stopped.
- * @return the parsed date
- * @throws ParseException if the date is not in the appropriate format
- */
- public static Date parse(String date, ParsePosition pos) throws ParseException {
- Exception fail = null;
- try {
- int offset = pos.getIndex();
-
- // extract year
- int year = parseInt(date, offset, offset += 4);
- if (checkOffset(date, offset, '-')) {
- offset += 1;
- }
-
- // extract month
- int month = parseInt(date, offset, offset += 2);
- if (checkOffset(date, offset, '-')) {
- offset += 1;
- }
-
- // extract day
- int day = parseInt(date, offset, offset += 2);
- // default time value
- int hour = 0;
- int minutes = 0;
- int seconds = 0;
- int milliseconds = 0; // always use 0 otherwise returned date will include millis of current time
-
- // if the value has no time component (and no time zone), we are done
- boolean hasT = checkOffset(date, offset, 'T');
-
- if (!hasT && (date.length() <= offset)) {
- Calendar calendar = new GregorianCalendar(year, month - 1, day);
-
- pos.setIndex(offset);
- return calendar.getTime();
- }
-
- if (hasT) {
-
- // extract hours, minutes, seconds and milliseconds
- hour = parseInt(date, offset += 1, offset += 2);
- if (checkOffset(date, offset, ':')) {
- offset += 1;
- }
-
- minutes = parseInt(date, offset, offset += 2);
- if (checkOffset(date, offset, ':')) {
- offset += 1;
- }
- // second and milliseconds can be optional
- if (date.length() > offset) {
- char c = date.charAt(offset);
- if (c != 'Z' && c != '+' && c != '-') {
- seconds = parseInt(date, offset, offset += 2);
- if (seconds > 59 && seconds < 63) seconds = 59; // truncate up to 3 leap seconds
- // milliseconds can be optional in the format
- if (checkOffset(date, offset, '.')) {
- offset += 1;
- int endOffset = indexOfNonDigit(date, offset + 1); // assume at least one digit
- int parseEndOffset = Math.min(endOffset, offset + 3); // parse up to 3 digits
- int fraction = parseInt(date, offset, parseEndOffset);
- // compensate for "missing" digits
- switch (parseEndOffset - offset) { // number of digits parsed
- case 2:
- milliseconds = fraction * 10;
- break;
- case 1:
- milliseconds = fraction * 100;
- break;
- default:
- milliseconds = fraction;
- }
- offset = endOffset;
- }
- }
- }
- }
-
- // extract timezone
- if (date.length() <= offset) {
- throw new IllegalArgumentException("No time zone indicator");
- }
-
- TimeZone timezone = null;
- char timezoneIndicator = date.charAt(offset);
-
- if (timezoneIndicator == 'Z') {
- timezone = TIMEZONE_UTC;
- offset += 1;
- } else if (timezoneIndicator == '+' || timezoneIndicator == '-') {
- String timezoneOffset = date.substring(offset);
-
- // When timezone has no minutes, we should append it, valid timezones are, for example: +00:00, +0000 and +00
- timezoneOffset = timezoneOffset.length() >= 5 ? timezoneOffset : timezoneOffset + "00";
-
- offset += timezoneOffset.length();
- // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00"
- if ("+0000".equals(timezoneOffset) || "+00:00".equals(timezoneOffset)) {
- timezone = TIMEZONE_UTC;
- } else {
- // 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC...
- // not sure why, but that's the way it looks. Further, Javadocs for
- // `java.util.TimeZone` specifically instruct use of GMT as base for
- // custom timezones... odd.
- String timezoneId = "GMT" + timezoneOffset;
-// String timezoneId = "UTC" + timezoneOffset;
-
- timezone = TimeZone.getTimeZone(timezoneId);
-
- String act = timezone.getID();
- if (!act.equals(timezoneId)) {
- /* 22-Jan-2015, tatu: Looks like canonical version has colons, but we may be given
- * one without. If so, don't sweat.
- * Yes, very inefficient. Hopefully not hit often.
- * If it becomes a perf problem, add 'loose' comparison instead.
- */
- String cleaned = act.replace(":", "");
- if (!cleaned.equals(timezoneId)) {
- throw new IndexOutOfBoundsException("Mismatching time zone indicator: "+timezoneId+" given, resolves to "
- +timezone.getID());
- }
- }
- }
- } else {
- throw new IndexOutOfBoundsException("Invalid time zone indicator '" + timezoneIndicator+"'");
- }
-
- Calendar calendar = new GregorianCalendar(timezone);
- 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, minutes);
- calendar.set(Calendar.SECOND, seconds);
- calendar.set(Calendar.MILLISECOND, milliseconds);
-
- pos.setIndex(offset);
- return calendar.getTime();
- // If we get a ParseException it'll already have the right message/offset.
- // Other exception types can convert here.
- } catch (IndexOutOfBoundsException e) {
- fail = e;
- } catch (NumberFormatException e) {
- fail = e;
- } catch (IllegalArgumentException e) {
- fail = e;
- }
- String input = (date == null) ? null : ('"' + date + '"');
- String msg = fail.getMessage();
- if (msg == null || msg.isEmpty()) {
- msg = "("+fail.getClass().getName()+")";
- }
- ParseException ex = new ParseException("Failed to parse date [" + input + "]: " + msg, pos.getIndex());
- ex.initCause(fail);
- throw ex;
- }
-
- /**
- * Check if the expected character exist at the given offset in the value.
- *
- * @param value the string to check at the specified offset
- * @param offset the offset to look for the expected character
- * @param expected the expected character
- * @return true if the expected character exist at the given offset
- */
- private static boolean checkOffset(String value, int offset, char expected) {
- return (offset < value.length()) && (value.charAt(offset) == expected);
- }
-
- /**
- * Parse an integer located between 2 given offsets in a string
- *
- * @param value the string to parse
- * @param beginIndex the start index for the integer in the string
- * @param endIndex the end index for the integer in the string
- * @return the int
- * @throws NumberFormatException if the value is not a number
- */
- private static int parseInt(String value, int beginIndex, int endIndex) throws NumberFormatException {
- if (beginIndex < 0 || endIndex > value.length() || beginIndex > endIndex) {
- throw new NumberFormatException(value);
- }
- // use same logic as in Integer.parseInt() but less generic we're not supporting negative values
- int i = beginIndex;
- int result = 0;
- int digit;
- if (i < endIndex) {
- digit = Character.digit(value.charAt(i++), 10);
- if (digit < 0) {
- throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex));
- }
- result = -digit;
- }
- while (i < endIndex) {
- digit = Character.digit(value.charAt(i++), 10);
- if (digit < 0) {
- throw new NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex));
- }
- result *= 10;
- result -= digit;
- }
- return -result;
- }
-
- /**
- * Zero pad a number to a specified length
- *
- * @param buffer buffer to use for padding
- * @param value the integer value to pad if necessary.
- * @param length the length of the string we should zero pad
- */
- private static void padInt(StringBuilder buffer, int value, int length) {
- String strValue = Integer.toString(value);
- for (int i = length - strValue.length(); i > 0; i--) {
- buffer.append('0');
- }
- buffer.append(strValue);
- }
-
- /**
- * Returns the index of the first character in the string that is not a digit, starting at offset.
- */
- private static int indexOfNonDigit(String string, int offset) {
- for (int i = offset; i < string.length(); i++) {
- 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..2718ef86a83 100644
--- a/sentry/src/test/java/io/sentry/DateUtilsTest.kt
+++ b/sentry/src/test/java/io/sentry/DateUtilsTest.kt
@@ -4,9 +4,13 @@ import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
+import java.util.Calendar
import java.util.Date
+import java.util.GregorianCalendar
+import java.util.TimeZone
import kotlin.test.Test
import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
@@ -117,6 +121,169 @@ class DateUtilsTest {
assertClose(0.000123456, seconds)
}
+ @Test
+ fun `format produces millis with Z suffix`() {
+ val time = 1530209176870L
+ val date = Date(time)
+ val dateStr = DateUtils.getTimestamp(date)
+ assertEquals("2018-06-28T18:06:16.870Z", dateStr)
+ }
+
+ @Test
+ fun `parse with timezone offset`() {
+ val date = DateUtils.getDateTime("2018-06-25T00:00:00-03:00")
+ val calendar = createUtcCalendar()
+ calendar.set(2018, Calendar.JUNE, 25, 3, 0)
+ assertEquals(calendar.time, date)
+ }
+
+ @Test
+ fun `parse with special timezone offset`() {
+ val date = DateUtils.getDateTime("2018-06-25T00:02:00-02:58")
+ val calendar = createUtcCalendar()
+ calendar.set(2018, Calendar.JUNE, 25, 3, 0)
+ assertEquals(calendar.time, date)
+ }
+
+ @Test
+ fun `parse rejects invalid time`() {
+ assertFailsWith { DateUtils.getDateTime("2018-06-25T61:60:62-03:00") }
+ }
+
+ // region Legacy fallback tests
+
+ @Test
+ fun `legacy format produces millis with Z suffix`() {
+ val time = 1530209176870L
+ val date = Date(time)
+ val dateStr = DateUtils.Iso8601Legacy.format(date)
+ assertEquals("2018-06-28T18:06:16.870Z", dateStr)
+ }
+
+ @Test
+ fun `legacy parse with Z timezone`() {
+ val date = DateUtils.Iso8601Legacy.parse("2020-03-27T08:52:58.015Z")
+ val utcDate = convertDate(date)
+ val timestamp = utcDate.format(isoFormat)
+ assertEquals("2020-03-27T08:52:58.015Z", timestamp)
+ }
+
+ @Test
+ fun `legacy parse without millis`() {
+ val date = DateUtils.Iso8601Legacy.parse("2020-03-27T08:52:58Z")
+ val utcDate = convertDate(date)
+ val timestamp = utcDate.format(isoFormat)
+ assertEquals("2020-03-27T08:52:58.000Z", timestamp)
+ }
+
+ @Test
+ fun `legacy parse with timezone offset`() {
+ val date = DateUtils.Iso8601Legacy.parse("2018-06-25T00:00:00-03:00")
+ val calendar = createUtcCalendar()
+ calendar.set(2018, Calendar.JUNE, 25, 3, 0)
+ assertEquals(calendar.time, date)
+ }
+
+ @Test
+ fun `legacy parse date only`() {
+ val date = DateUtils.Iso8601Legacy.parse("2018-06-25")
+ val expected = GregorianCalendar(2018, Calendar.JUNE, 25).time
+ assertEquals(expected, date)
+ }
+
+ @Test
+ fun `legacy roundtrip`() {
+ val original = DateUtils.getCurrentDateTime()
+ val iso = DateUtils.Iso8601Legacy.format(original)
+ val parsed = DateUtils.Iso8601Legacy.parse(iso)
+ val iso2 = DateUtils.Iso8601Legacy.format(parsed)
+ assertEquals(iso, iso2)
+ assertEquals(original, parsed)
+ }
+
+ // endregion
+
+ // region java.time tests
+
+ @Test
+ fun `java time format produces millis with Z suffix`() {
+ assertTrue(DateUtils.HAS_JAVA_TIME, "java.time should be available on JVM")
+ val time = 1530209176870L
+ val date = Date(time)
+ val dateStr = DateUtils.Iso8601JavaTime.format(date)
+ assertEquals("2018-06-28T18:06:16.870Z", dateStr)
+ }
+
+ @Test
+ fun `java time parse with Z timezone`() {
+ val date = DateUtils.Iso8601JavaTime.parse("2020-03-27T08:52:58.015Z")
+ val utcDate = convertDate(date)
+ val timestamp = utcDate.format(isoFormat)
+ assertEquals("2020-03-27T08:52:58.015Z", timestamp)
+ }
+
+ @Test
+ fun `java time parse without millis`() {
+ val date = DateUtils.Iso8601JavaTime.parse("2020-03-27T08:52:58Z")
+ val utcDate = convertDate(date)
+ val timestamp = utcDate.format(isoFormat)
+ assertEquals("2020-03-27T08:52:58.000Z", timestamp)
+ }
+
+ @Test
+ fun `java time parse with timezone offset`() {
+ val date = DateUtils.Iso8601JavaTime.parse("2018-06-25T00:00:00-03:00")
+ val calendar = createUtcCalendar()
+ calendar.set(2018, Calendar.JUNE, 25, 3, 0)
+ assertEquals(calendar.time, date)
+ }
+
+ @Test
+ fun `java time roundtrip`() {
+ val original = DateUtils.getCurrentDateTime()
+ val iso = DateUtils.Iso8601JavaTime.format(original)
+ val parsed = DateUtils.Iso8601JavaTime.parse(iso)
+ val iso2 = DateUtils.Iso8601JavaTime.format(parsed)
+ assertEquals(iso, iso2)
+ assertEquals(original, parsed)
+ }
+
+ @Test
+ fun `both implementations produce identical output`() {
+ val dates =
+ listOf(Date(0), Date(1530209176870L), Date(1591533492631L), DateUtils.getCurrentDateTime())
+ dates.forEach { date ->
+ val javaTime = DateUtils.Iso8601JavaTime.format(date)
+ val legacy = DateUtils.Iso8601Legacy.format(date)
+ assertEquals(legacy, javaTime, "Mismatch for date: $date")
+ }
+ }
+
+ @Test
+ fun `both implementations parse identically`() {
+ val timestamps =
+ listOf(
+ "2020-03-27T08:52:58.015Z",
+ "2020-03-27T08:52:58Z",
+ "2018-06-25T00:00:00-03:00",
+ "2018-06-25T00:02:00-02:58",
+ )
+ timestamps.forEach { ts ->
+ val javaTime = DateUtils.Iso8601JavaTime.parse(ts)
+ val legacy = DateUtils.Iso8601Legacy.parse(ts)
+ assertEquals(legacy, javaTime, "Mismatch for timestamp: $ts")
+ }
+ }
+
+ // endregion
+
+ private fun createUtcCalendar(): GregorianCalendar {
+ val utc = TimeZone.getTimeZone("UTC")
+ val calendar = GregorianCalendar(utc)
+ calendar.clear()
+ return calendar
+ }
+
private fun convertDate(date: Date): LocalDateTime =
Instant.ofEpochMilli(date.time).atZone(utcTimeZone).toLocalDateTime()
diff --git a/sentry/src/test/java/io/sentry/vendor/gson/internal/bind/util/ISO8601UtilsTest.java b/sentry/src/test/java/io/sentry/vendor/gson/internal/bind/util/ISO8601UtilsTest.java
deleted file mode 100644
index 70a209f1fe1..00000000000
--- a/sentry/src/test/java/io/sentry/vendor/gson/internal/bind/util/ISO8601UtilsTest.java
+++ /dev/null
@@ -1,114 +0,0 @@
-/*
- * Adapted from https://github.com/FasterXML/jackson-databind/blob/c1e92435c6942386394a2a7733065bb047773107/src/test/java/com/fasterxml/jackson/databind/util/ISO8601UtilsTest.java
- *
- * Copyright (C) 2007-, Tatu Saloranta
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package io.sentry.vendor.gson.internal.bind.util;
-
-import org.junit.Test;
-
-import java.text.ParseException;
-import java.text.ParsePosition;
-import java.util.*;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertTrue;
-
-public class ISO8601UtilsTest {
-
- private static TimeZone utcTimeZone() {
- return TimeZone.getTimeZone("UTC");
- }
-
- private static GregorianCalendar createUtcCalendar() {
- TimeZone utc = utcTimeZone();
- GregorianCalendar calendar = new GregorianCalendar(utc);
- // Calendar was created with current time, must clear it
- calendar.clear();
- return calendar;
- }
-
- @Test
- public void testDateFormatString() {
- GregorianCalendar calendar = new GregorianCalendar(utcTimeZone(), Locale.US);
- // Calendar was created with current time, must clear it
- calendar.clear();
- calendar.set(2018, Calendar.JUNE, 25);
- Date date = calendar.getTime();
- String dateStr = ISO8601Utils.format(date);
- String expectedDate = "2018-06-25";
- assertEquals(expectedDate, dateStr.substring(0, expectedDate.length()));
- }
-
- @Test
- public void testDateFormatWithMilliseconds() {
- long time = 1530209176870L;
- Date date = new Date(time);
- String dateStr = ISO8601Utils.format(date, true);
- String expectedDate = "2018-06-28T18:06:16.870Z";
- assertEquals(expectedDate, dateStr);
- }
-
- @Test
- public void testDateFormatWithTimezone() {
- long time = 1530209176870L;
- Date date = new Date(time);
- String dateStr = ISO8601Utils.format(date, true, TimeZone.getTimeZone("Brazil/East"));
- String expectedDate = "2018-06-28T15:06:16.870-03:00";
- assertEquals(expectedDate, dateStr);
- }
-
- @Test
- public void testDateParseWithDefaultTimezone() throws ParseException {
- String dateStr = "2018-06-25";
- Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0));
- Date expectedDate = new GregorianCalendar(2018, Calendar.JUNE, 25).getTime();
- assertEquals(expectedDate, date);
- }
-
- @Test
- public void testDateParseWithTimezone() throws ParseException {
- String dateStr = "2018-06-25T00:00:00-03:00";
- Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0));
- GregorianCalendar calendar = createUtcCalendar();
- calendar.set(2018, Calendar.JUNE, 25, 3, 0);
- Date expectedDate = calendar.getTime();
- assertEquals(expectedDate, date);
- }
-
- @Test
- public void testDateParseSpecialTimezone() throws ParseException {
- String dateStr = "2018-06-25T00:02:00-02:58";
- Date date = ISO8601Utils.parse(dateStr, new ParsePosition(0));
- GregorianCalendar calendar = createUtcCalendar();
- calendar.set(2018, Calendar.JUNE, 25, 3, 0);
- Date expectedDate = calendar.getTime();
- assertEquals(expectedDate, date);
- }
-
- @Test
- public void testDateParseInvalidTime() {
- String dateStr = "2018-06-25T61:60:62-03:00";
- boolean thrown = false;
- try {
- ISO8601Utils.parse(dateStr, new ParsePosition(0));
- } catch (ParseException e) {
- thrown = true;
- }
- assertTrue("Expected to throw a ParseException, but failed.", thrown);
- }
-}
-