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); - } -} -