From a275170d8b0991042b9ff1d06cbac293e7091016 Mon Sep 17 00:00:00 2001 From: arimu1 <19286898+arimu1@users.noreply.github.com> Date: Fri, 7 Aug 2026 23:02:43 +0700 Subject: [PATCH] fix: accept NNBSP before AM/PM in default Date parsing JDK 21+ CLDR uses U+202F (narrow no-break space) before AM/PM in DateFormat output, while older JDKs use a regular space. Default Date deserialization now retries with both space variants so dates remain interoperable across JDK versions. Fixes #2689 --- .../internal/bind/DefaultDateTypeAdapter.java | 95 ++++++++++++++++++- .../bind/DefaultDateTypeAdapterTest.java | 35 +++++++ 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/gson/src/main/java/com/google/gson/internal/bind/DefaultDateTypeAdapter.java b/gson/src/main/java/com/google/gson/internal/bind/DefaultDateTypeAdapter.java index b5dffe24fb..fe138a25c8 100644 --- a/gson/src/main/java/com/google/gson/internal/bind/DefaultDateTypeAdapter.java +++ b/gson/src/main/java/com/google/gson/internal/bind/DefaultDateTypeAdapter.java @@ -55,6 +55,12 @@ public final class DefaultDateTypeAdapter extends TypeAdapter { private static final String SIMPLE_NAME = "DefaultDateTypeAdapter"; + /** Narrow no-break space (U+202F), used before AM/PM by CLDR 42+ (JDK 21+). */ + private static final char NARROW_NO_BREAK_SPACE = '\u202f'; + + /** No-break space (U+00A0). */ + private static final char NO_BREAK_SPACE = '\u00a0'; + /** Factory for {@link Date} adapters which use {@link DateFormat#DEFAULT} as style. */ public static final TypeAdapterFactory DEFAULT_STYLE_FACTORY = // Because SimpleDateFormat captures the default TimeZone when it was created, let the factory @@ -169,9 +175,10 @@ private Date deserializeToDate(JsonReader in) throws IOException { for (DateFormat dateFormat : dateFormats) { TimeZone originalTimeZone = dateFormat.getTimeZone(); try { - return dateFormat.parse(s); - } catch (ParseException ignored) { - // OK: try the next format + Date parsed = parseDateWithSpaceVariants(dateFormat, s); + if (parsed != null) { + return parsed; + } } finally { dateFormat.setTimeZone(originalTimeZone); } @@ -186,6 +193,88 @@ private Date deserializeToDate(JsonReader in) throws IOException { } } + /** + * Parses {@code s} with {@code dateFormat}, retrying with alternate space characters around the + * AM/PM marker. + * + *

Starting with the CLDR 42 update in JDK 21 ({@code JDK-8284840}), {@link + * DateFormat#getDateTimeInstance} for some locales uses U+202F (narrow no-break space) before + * AM/PM instead of U+0020. Dates serialized on one JDK version may therefore fail to parse on + * another. Trying both space variants keeps default {@code Date} adapters interoperable. + * + * @return the parsed date, or {@code null} if none of the variants could be parsed + */ + private static Date parseDateWithSpaceVariants(DateFormat dateFormat, String s) { + try { + return dateFormat.parse(s); + } catch (ParseException ignored) { + // try alternate spaces below + } + + for (String variant : alternateAmPmSpaceVariants(s)) { + try { + return dateFormat.parse(variant); + } catch (ParseException ignored) { + // OK: try the next variant + } + } + return null; + } + + /** + * Returns alternate forms of {@code s} where the space before an AM/PM marker is swapped between + * U+0020 and U+202F (narrow no-break space). Also normalizes U+00A0 (no-break space) to U+0020. + * Does not include {@code s} itself. + */ + private static String[] alternateAmPmSpaceVariants(String s) { + // First normalize any no-break spaces to regular space so we have a canonical baseline. + String withRegularSpace = s.replace(NARROW_NO_BREAK_SPACE, ' ').replace(NO_BREAK_SPACE, ' '); + + String withNarrowNoBreakSpace = replaceSpaceBeforeAmPm(withRegularSpace, NARROW_NO_BREAK_SPACE); + + // Collect unique variants different from the original input. + // At most two: regular-space form and NNBSP form. + if (withRegularSpace.equals(s)) { + if (withNarrowNoBreakSpace.equals(s)) { + return new String[0]; + } + return new String[] {withNarrowNoBreakSpace}; + } + if (withNarrowNoBreakSpace.equals(s) || withNarrowNoBreakSpace.equals(withRegularSpace)) { + return new String[] {withRegularSpace}; + } + return new String[] {withRegularSpace, withNarrowNoBreakSpace}; + } + + /** + * Replaces a single ASCII space immediately before an AM/PM marker with {@code spaceChar}. If no + * such marker is found, returns {@code s} unchanged. + */ + private static String replaceSpaceBeforeAmPm(String s, char spaceChar) { + int length = s.length(); + for (int i = 0; i < length - 2; i++) { + if (s.charAt(i) != ' ') { + continue; + } + char first = s.charAt(i + 1); + char second = s.charAt(i + 2); + if (!isAmPmLetter(first, second)) { + continue; + } + // Require end of string or a non-letter after "AM"/"PM" so we do not match prefixes. + if (i + 3 < length && Character.isLetter(s.charAt(i + 3))) { + continue; + } + return s.substring(0, i) + spaceChar + s.substring(i + 1); + } + return s; + } + + private static boolean isAmPmLetter(char first, char second) { + return (first == 'A' || first == 'a' || first == 'P' || first == 'p') + && (second == 'M' || second == 'm'); + } + @Override public String toString() { DateFormat defaultFormat = dateFormats.get(0); diff --git a/gson/src/test/java/com/google/gson/internal/bind/DefaultDateTypeAdapterTest.java b/gson/src/test/java/com/google/gson/internal/bind/DefaultDateTypeAdapterTest.java index 81e1c28a8b..6febd21e61 100644 --- a/gson/src/test/java/com/google/gson/internal/bind/DefaultDateTypeAdapterTest.java +++ b/gson/src/test/java/com/google/gson/internal/bind/DefaultDateTypeAdapterTest.java @@ -161,6 +161,41 @@ public void testDateDeserializationISO8601() throws Exception { assertParsed("1970-01-01T01:00:00+01", adapterFactory); } + /** + * JDK 21+ CLDR uses U+202F (narrow no-break space) before AM/PM in default DateFormat output; + * older JDKs use a regular space. Parsing must accept both so dates remain interoperable across + * JDK versions (see GitHub issue #2689). + */ + @Test + public void testParsingAcceptsNarrowNoBreakSpaceBeforeAmPm() throws Exception { + TimeZone defaultTimeZone = TimeZone.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); + Locale defaultLocale = Locale.getDefault(); + Locale.setDefault(Locale.US); + try { + TypeAdapter adapter = dateAdapter(DefaultDateTypeAdapter.DEFAULT_STYLE_FACTORY); + // US MEDIUM DateFormat style after Java 9 uses a comma after the year. + String withRegularSpace = "Mar 21, 2022, 11:03:07 AM"; + String withNarrowNoBreakSpace = "Mar 21, 2022, 11:03:07\u202FAM"; + String withNoBreakSpace = "Mar 21, 2022, 11:03:07\u00A0AM"; + String withPmRegularSpace = "Mar 21, 2022, 4:45:51 PM"; + String withPmNarrowNoBreakSpace = "Mar 21, 2022, 4:45:51\u202FPM"; + + Date fromRegular = adapter.fromJson(toLiteral(withRegularSpace)); + Date fromNnbsp = adapter.fromJson(toLiteral(withNarrowNoBreakSpace)); + Date fromNbsp = adapter.fromJson(toLiteral(withNoBreakSpace)); + assertThat(fromNnbsp.getTime()).isEqualTo(fromRegular.getTime()); + assertThat(fromNbsp.getTime()).isEqualTo(fromRegular.getTime()); + + Date fromPmRegular = adapter.fromJson(toLiteral(withPmRegularSpace)); + Date fromPmNnbsp = adapter.fromJson(toLiteral(withPmNarrowNoBreakSpace)); + assertThat(fromPmNnbsp.getTime()).isEqualTo(fromPmRegular.getTime()); + } finally { + TimeZone.setDefault(defaultTimeZone); + Locale.setDefault(defaultLocale); + } + } + @Test public void testDatePattern() { String pattern = "yyyy-MM-dd";