From 151b8e3277b017f7379db43b4491566e1a430eb4 Mon Sep 17 00:00:00 2001 From: Alexander Zhirkevich Date: Thu, 23 May 2024 18:21:22 +0300 Subject: [PATCH] Fix web datepicker text field illegal input crash (#1368) - Update kotlinx.datetime to [0.6.0](https://github.com/Kotlin/kotlinx-datetime/releases/tag/v0.6.0) - Migrate locale-invariant web formatting/parsing to kotlinx.datetime - Catch parsing exceptions Fixes https://github.com/JetBrains/compose-multiplatform/issues/4856 ## Testing `KotlinxDatetimeCalendarModelTest.illegalDateParsingDoesNotThrowException` This should be tested by QA ## Release Notes ### Fixes - Web - Fixed crash when date picker text field receives illegal input --- .../KotlinxDatetimeCalendarModelTest.kt | 9 ++ .../material3/PlatformDateFormat.jsWasm.kt | 89 ++++++++----------- gradle/libs.versions.toml | 2 +- 3 files changed, 49 insertions(+), 51 deletions(-) diff --git a/compose/material3/material3/src/skikoTest/kotlin/androidx/compose/material3/KotlinxDatetimeCalendarModelTest.kt b/compose/material3/material3/src/skikoTest/kotlin/androidx/compose/material3/KotlinxDatetimeCalendarModelTest.kt index 4fe15cf4a31ed..815748e4de73f 100644 --- a/compose/material3/material3/src/skikoTest/kotlin/androidx/compose/material3/KotlinxDatetimeCalendarModelTest.kt +++ b/compose/material3/material3/src/skikoTest/kotlin/androidx/compose/material3/KotlinxDatetimeCalendarModelTest.kt @@ -413,6 +413,15 @@ internal class KotlinxDatetimeCalendarModelTest { assertThat(model.getDateInputFormat(locale).patternWithoutDelimiters).isEqualTo("ddMMyyyy") assertThat(model.getDateInputFormat(locale).delimiter).isEqualTo('-') } + + @Test + fun illegalDateParsingDoesNotThrowException(){ + val model = KotlinxDatetimeCalendarModel(calendarLocale("en","US")) + + assertThat(model.parse("50-50-2000","MM-dd-yyyy")).isEqualTo(null) + assertThat(model.parse("50-50-2000","")).isEqualTo(null) + assertThat(model.parse("","MM-dd-yyyy")).isEqualTo(null) + } } internal const val January2022Millis = 1640995200000 diff --git a/compose/material3/material3/src/webCommonW3C/kotlin/androidx/compose/material3/PlatformDateFormat.jsWasm.kt b/compose/material3/material3/src/webCommonW3C/kotlin/androidx/compose/material3/PlatformDateFormat.jsWasm.kt index 5d75e35b165da..b6d18b3eddb70 100644 --- a/compose/material3/material3/src/webCommonW3C/kotlin/androidx/compose/material3/PlatformDateFormat.jsWasm.kt +++ b/compose/material3/material3/src/webCommonW3C/kotlin/androidx/compose/material3/PlatformDateFormat.jsWasm.kt @@ -19,8 +19,12 @@ package androidx.compose.material3 import androidx.compose.ui.text.intl.Locale import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate +import kotlinx.datetime.LocalDateTime import kotlinx.datetime.TimeZone import kotlinx.datetime.atTime +import kotlinx.datetime.format +import kotlinx.datetime.format.FormatStringsInDatetimeFormats +import kotlinx.datetime.format.byUnicodePattern import kotlinx.datetime.toInstant import kotlinx.datetime.toLocalDateTime @@ -44,7 +48,8 @@ internal actual class PlatformDateFormat actual constructor(private val locale: listOf("AE", "AG", "AL", "AS", "AU", "BB", "BD", "BH", "BM", "BN", "BS", "BT", "CA", "CN", "CO", "CY", "DJ", "DM", "DO", "DZ", "EG", "EH", "ER", "ET", "FJ", "FM", "GD", "GH", "GM", "GR", "GU", "GY", "HK", "IN", "IQ", "JM", "JO", "KH", "KI", "KN", "KP", "KR", "KW", "KY", "LB", "LC", "LR", "LS", "LY", "MH", "MO", "MP", "MR", "MW", "MY", "NA", "NZ", "OM", "PA", "PG", "PH", "PK", "PR", "PS", "PW", "QA", "SA", "SB", "SD", "SG", "SL", "SO", "SS", "SY", "SZ", "TC", "TD", "TN", "TO", "TT", "TW", "UM", "US", "VC", "VE", "VG", "VI", "VU", "WS", "YE", "ZM") } - //TODO: replace formatting with kotlinx datetime when supported (see https://github.com/Kotlin/kotlinx-datetime/pull/251) + //TODO: replace manual locale-aware formatting with kotlinx-datetime when supported + @OptIn(FormatStringsInDatetimeFormats::class) actual fun formatWithPattern( utcTimeMillis: Long, pattern: String, @@ -56,36 +61,32 @@ internal actual class PlatformDateFormat actual constructor(private val locale: val jsDate = Date(utcTimeMillis.toDouble()) - val monthShort = jsDate.toLocaleDateString( - locales = locale.toLanguageTag(), - options = dateLocaleOptions { - month = SHORT - }) + val (monthShort, monthLong) = listOf(SHORT, LONG).map { + jsDate.toLocaleDateString( + locales = locale.toLanguageTag(), + options = dateLocaleOptions { + month = it + } + ) + } - val monthLong = jsDate.toLocaleDateString( - locales = locale.toLanguageTag(), - options = dateLocaleOptions { - month = LONG - }) + val (wdShort, wdLong) = listOf(SHORT, LONG).map { + jsDate.toLocaleDateString( + locales = locale.toLanguageTag(), + options = dateLocaleOptions { + weekday = it + } + ) + } - return pattern - .replace("yyyy", date.year.toString(), ignoreCase = true) - .replace("yy", date.year.toString().takeLast(2), ignoreCase = true) + return date + .format(LocalDateTime.Format { byUnicodePattern(pattern) }) .replace("MMMM", monthLong) .replace("MMM", monthShort) - .replace("MM", date.monthNumber.toStringWithLeadingZero()) - .replace("M", date.monthNumber.toString()) - .replace("dd", date.dayOfMonth.toStringWithLeadingZero(), ignoreCase = true) - .replace("d", date.dayOfMonth.toString(), ignoreCase = true) - .replace("hh", date.hour.toStringWithLeadingZero(), ignoreCase = true) - .replace("h", date.hour.toString(), ignoreCase = true) - .replace("ii", date.minute.toStringWithLeadingZero(), ignoreCase = true) - .replace("i", date.minute.toString(), ignoreCase = true) - .replace("ss", date.second.toStringWithLeadingZero(), ignoreCase = true) - .replace("s", date.second.toString(), ignoreCase = true) + .replace("EEEE", wdLong) + .replace("EEE", wdShort) } - actual fun formatWithSkeleton( utcTimeMillis: Long, skeleton: String, @@ -137,35 +138,23 @@ internal actual class PlatformDateFormat actual constructor(private val locale: ) } + @OptIn(FormatStringsInDatetimeFormats::class) actual fun parse( date: String, pattern: String ): CalendarDate? { - val year = parseSegment(date, pattern, "yyyy") - ?: return null - - val month = parseSegment(date, pattern, "mm") - ?: parseSegment(date, pattern, "m") - ?: return null - - val day = parseSegment(date, pattern, "dd") - ?: parseSegment(date, pattern, "d") - ?: 1 - - return LocalDate( - year, month, day - ).atTime(Midnight) - .toInstant(TimeZone.UTC) - .toCalendarDate(TimeZone.UTC) - } - - private fun parseSegment(date: String, pattern: String, segmentPattern: String): Int? { - val index = pattern - .indexOf(segmentPattern, ignoreCase = true) - .takeIf { it >= 0 } ?: return null - - return date.substring(index, index + segmentPattern.length) - .toIntOrNull() + return try { + LocalDate.parse( + input = date, + format = LocalDate.Format { + byUnicodePattern(pattern) + } + ).atTime(Midnight) + .toInstant(TimeZone.UTC) + .toCalendarDate(TimeZone.UTC) + } catch (e: Throwable) { + null + } } actual fun getDateInputFormat(): DateInputFormat { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5950f83149755..0d60db54f975c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ byteBuddy = "1.12.10" asm = "9.3" cmake = "3.22.1" dagger = "2.44" -datetime="0.5.0" +datetime="0.6.0" dexmaker = "2.28.3" dokka = "1.8.10-dev-203" espresso = "3.6.0-alpha01"