From 3417a7272aaa8f633575fac47d662a804f8f677a Mon Sep 17 00:00:00 2001 From: Konrad Pozniak Date: Thu, 16 Jun 2022 18:51:35 +0200 Subject: [PATCH] use moshis Rfc3339DateJsonAdapter for json date parsing (#2584) * Rename .java to .kt * use moshis Rfc3339DateJsonAdapter for json date parsing --- .../keylesspalace/tusky/di/NetworkModule.kt | 4 +- .../keylesspalace/tusky/json/Iso8601Utils.kt | 268 +++++++++++++++++ .../tusky/json/Rfc3339DateJsonAdapter.kt | 49 +++ .../tusky/json/UtcDateTypeAdapter.java | 284 ------------------ 4 files changed, 319 insertions(+), 286 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt create mode 100644 app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt delete mode 100644 app/src/main/java/com/keylesspalace/tusky/json/UtcDateTypeAdapter.java diff --git a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt index 61c4ac88..8250e61f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/NetworkModule.kt @@ -23,7 +23,7 @@ import com.google.gson.Gson import com.google.gson.GsonBuilder import com.keylesspalace.tusky.BuildConfig import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.json.UtcDateTypeAdapter +import com.keylesspalace.tusky.json.Rfc3339DateJsonAdapter import com.keylesspalace.tusky.network.InstanceSwitchAuthInterceptor import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.network.MediaUploadApi @@ -54,7 +54,7 @@ class NetworkModule { @Provides @Singleton fun providesGson(): Gson = GsonBuilder() - .registerTypeAdapter(Date::class.java, UtcDateTypeAdapter()) + .registerTypeAdapter(Date::class.java, Rfc3339DateJsonAdapter()) .create() @Provides diff --git a/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt b/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt new file mode 100644 index 00000000..bd8df6b5 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/json/Iso8601Utils.kt @@ -0,0 +1,268 @@ +package com.keylesspalace.tusky.json + +/* + * Copyright (C) 2011 FasterXML, LLC + * + * 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 + * + * https://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. + */ + +import com.google.gson.JsonParseException +import java.util.Calendar +import java.util.Date +import java.util.GregorianCalendar +import java.util.Locale +import java.util.TimeZone +import kotlin.math.min +import kotlin.math.pow + +/* + * Jackson’s date formatter, pruned to Moshi's needs. Forked from this file: + * https://github.com/FasterXML/jackson-databind/blob/67ebf7305f492285a8f9f4de31545f5f16fc7c3a/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java + * + * 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](http://www.w3.org/TR/NOTE-datetime) + */ + +/** ID to represent the 'GMT' string */ +private const val GMT_ID = "GMT" + +/** The GMT timezone, prefetched to avoid more lookups. */ +private val TIMEZONE_Z: TimeZone = TimeZone.getTimeZone(GMT_ID) + +/** Returns `date` formatted as yyyy-MM-ddThh:mm:ss.sssZ */ +internal fun Date.formatIsoDate(): String { + val calendar: Calendar = GregorianCalendar(TIMEZONE_Z, Locale.US) + calendar.time = this + + // estimate capacity of buffer as close as we can (yeah, that's pedantic ;) + val capacity = "yyyy-MM-ddThh:mm:ss.sssZ".length + val formatted = StringBuilder(capacity) + padInt(formatted, calendar[Calendar.YEAR], "yyyy".length) + formatted.append('-') + padInt(formatted, calendar[Calendar.MONTH] + 1, "MM".length) + formatted.append('-') + padInt(formatted, calendar[Calendar.DAY_OF_MONTH], "dd".length) + formatted.append('T') + padInt(formatted, calendar[Calendar.HOUR_OF_DAY], "hh".length) + formatted.append(':') + padInt(formatted, calendar[Calendar.MINUTE], "mm".length) + formatted.append(':') + padInt(formatted, calendar[Calendar.SECOND], "ss".length) + formatted.append('.') + padInt(formatted, calendar[Calendar.MILLISECOND], "sss".length) + formatted.append('Z') + return formatted.toString() +} + +/** + * 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]]` + * + * @receiver ISO string to parse in the appropriate format. + * @return the parsed date + */ +internal fun String.parseIsoDate(): Date { + return try { + var offset = 0 + + // extract year + val year = parseInt(this, offset, 4.let { offset += it; offset }) + if (checkOffset(this, offset, '-')) { + offset += 1 + } + + // extract month + val month = parseInt(this, offset, 2.let { offset += it; offset }) + if (checkOffset(this, offset, '-')) { + offset += 1 + } + + // extract day + val day = parseInt(this, offset, 2.let { offset += it; offset }) + // default time value + var hour = 0 + var minutes = 0 + var seconds = 0 + // always use 0 otherwise returned date will include millis of current time + var milliseconds = 0 + + // if the value has no time component (and no time zone), we are done + val hasT = checkOffset(this, offset, 'T') + if (!hasT && this.length <= offset) { + return GregorianCalendar(year, month - 1, day).time + } + if (hasT) { + + // extract hours, minutes, seconds and milliseconds + hour = parseInt(this, 1.let { offset += it; offset }, 2.let { offset += it; offset }) + if (checkOffset(this, offset, ':')) { + offset += 1 + } + minutes = parseInt(this, offset, 2.let { offset += it; offset }) + if (checkOffset(this, offset, ':')) { + offset += 1 + } + // second and milliseconds can be optional + if (this.length > offset) { + val c = this[offset] + if (c != 'Z' && c != '+' && c != '-') { + seconds = parseInt(this, offset, 2.let { offset += it; offset }) + if (seconds in 60..62) seconds = 59 // truncate up to 3 leap seconds + // milliseconds can be optional in the format + if (checkOffset(this, offset, '.')) { + offset += 1 + val endOffset = indexOfNonDigit(this, offset + 1) // assume at least one digit + val parseEndOffset = min(endOffset, offset + 3) // parse up to 3 digits + val fraction = parseInt(this, offset, parseEndOffset) + milliseconds = + (10.0.pow((3 - (parseEndOffset - offset)).toDouble()) * fraction).toInt() + offset = endOffset + } + } + } + } + + // extract timezone + require(this.length > offset) { "No time zone indicator" } + val timezone: TimeZone + val timezoneIndicator = this[offset] + if (timezoneIndicator == 'Z') { + timezone = TIMEZONE_Z + } else if (timezoneIndicator == '+' || timezoneIndicator == '-') { + val timezoneOffset = this.substring(offset) + // 18-Jun-2015, tatu: Minor simplification, skip offset of "+0000"/"+00:00" + if ("+0000" == timezoneOffset || "+00:00" == timezoneOffset) { + timezone = TIMEZONE_Z + } else { + // 18-Jun-2015, tatu: Looks like offsets only work from GMT, not UTC... + // not sure why, but it is what it is. + val timezoneId = GMT_ID + timezoneOffset + timezone = TimeZone.getTimeZone(timezoneId) + val act = timezone.id + if (act != 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. + */ + val cleaned = act.replace(":", "") + if (cleaned != timezoneId) { + throw IndexOutOfBoundsException( + "Mismatching time zone indicator: $timezoneId given, resolves to ${timezone.id}" + ) + } + } + } + } else { + throw IndexOutOfBoundsException( + "Invalid time zone indicator '$timezoneIndicator'" + ) + } + val calendar: Calendar = GregorianCalendar(timezone) + calendar.isLenient = false + calendar[Calendar.YEAR] = year + calendar[Calendar.MONTH] = month - 1 + calendar[Calendar.DAY_OF_MONTH] = day + calendar[Calendar.HOUR_OF_DAY] = hour + calendar[Calendar.MINUTE] = minutes + calendar[Calendar.SECOND] = seconds + calendar[Calendar.MILLISECOND] = milliseconds + calendar.time + // If we get a ParseException it'll already have the right message/offset. + // Other exception types can convert here. + } catch (e: IndexOutOfBoundsException) { + throw JsonParseException("Not an RFC 3339 date: $this", e) + } catch (e: IllegalArgumentException) { + throw JsonParseException("Not an RFC 3339 date: $this", e) + } +} + +/** + * 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 fun checkOffset(value: String, offset: Int, expected: Char): Boolean { + return offset < value.length && value[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 fun parseInt(value: String, beginIndex: Int, endIndex: Int): Int { + if (beginIndex < 0 || endIndex > value.length || beginIndex > endIndex) { + throw NumberFormatException(value) + } + // use same logic as in Integer.parseInt() but less generic we're not supporting negative values + var i = beginIndex + var result = 0 + var digit: Int + if (i < endIndex) { + digit = Character.digit(value[i++], 10) + if (digit < 0) { + throw NumberFormatException("Invalid number: " + value.substring(beginIndex, endIndex)) + } + result = -digit + } + while (i < endIndex) { + digit = Character.digit(value[i++], 10) + if (digit < 0) { + throw 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 fun padInt(buffer: StringBuilder, value: Int, length: Int) { + val strValue = value.toString() + for (i in length - strValue.length downTo 1) { + 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 fun indexOfNonDigit(string: String, offset: Int): Int { + for (i in offset until string.length) { + val c = string[i] + if (c < '0' || c > '9') return i + } + return string.length +} diff --git a/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt new file mode 100644 index 00000000..090fe5e3 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/json/Rfc3339DateJsonAdapter.kt @@ -0,0 +1,49 @@ +// https://github.com/google/gson/blob/master/extras/src/main/java/com/google/gson/typeadapters/UtcDateTypeAdapter.java +/* + * Copyright (C) 2011 Google Inc. + * + * 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 com.keylesspalace.tusky.json + +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonToken +import com.google.gson.stream.JsonWriter +import java.io.IOException +import java.util.Date + +class Rfc3339DateJsonAdapter : TypeAdapter() { + + @Throws(IOException::class) + override fun write(writer: JsonWriter, date: Date?) { + if (date == null) { + writer.nullValue() + } else { + writer.value(date.formatIsoDate()) + } + } + + @Throws(IOException::class) + override fun read(reader: JsonReader): Date? { + return when (reader.peek()) { + JsonToken.NULL -> { + reader.nextNull() + null + } + else -> { + reader.nextString().parseIsoDate() + } + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/json/UtcDateTypeAdapter.java b/app/src/main/java/com/keylesspalace/tusky/json/UtcDateTypeAdapter.java deleted file mode 100644 index d178b3fc..00000000 --- a/app/src/main/java/com/keylesspalace/tusky/json/UtcDateTypeAdapter.java +++ /dev/null @@ -1,284 +0,0 @@ -// https://github.com/google/gson/blob/master/extras/src/main/java/com/google/gson/typeadapters/UtcDateTypeAdapter.java - -/* - * Copyright (C) 2011 Google Inc. - * - * 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 com.keylesspalace.tusky.json; - -import java.io.IOException; -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 com.google.gson.JsonParseException; -import com.google.gson.TypeAdapter; -import com.google.gson.stream.JsonReader; -import com.google.gson.stream.JsonWriter; - -public final class UtcDateTypeAdapter extends TypeAdapter { - private final TimeZone UTC_TIME_ZONE = TimeZone.getTimeZone("UTC"); - - @Override - public void write(JsonWriter out, Date date) throws IOException { - if (date == null) { - out.nullValue(); - } else { - String value = format(date, true, UTC_TIME_ZONE); - out.value(value); - } - } - - @Override - public Date read(JsonReader in) throws IOException { - try { - switch (in.peek()) { - case NULL: - in.nextNull(); - return null; - default: - String date = in.nextString(); - // Instead of using iso8601Format.parse(value), we use Jackson's date parsing - // This is because Android doesn't support XXX because it is JDK 1.6 - return parse(date, new ParsePosition(0)); - } - } catch (ParseException e) { - throw new JsonParseException(e); - } - } - - // Date parsing code from Jackson databind ISO8601Utils.java - // https://github.com/FasterXML/jackson-databind/blob/master/src/main/java/com/fasterxml/jackson/databind/util/ISO8601Utils.java - private static final String GMT_ID = "GMT"; - - /** - * 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 (GMT will produce 'Z') - * @return the date formatted as yyyy-MM-ddThh:mm:ss[.sss][Z|[+-]hh:mm] - */ - private 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(); - } - /** - * 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); - } - - /** - * 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 - */ - private 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 (checkOffset(date, offset, 'T')) { - - // 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); - // milliseconds can be optional in the format - if (checkOffset(date, offset, '.')) { - milliseconds = parseInt(date, offset += 1, offset += 3); - } - } - } - } - - // extract timezone - String timezoneId; - if (date.length() <= offset) { - throw new IllegalArgumentException("No time zone indicator"); - } - char timezoneIndicator = date.charAt(offset); - if (timezoneIndicator == '+' || timezoneIndicator == '-') { - String timezoneOffset = date.substring(offset); - timezoneId = GMT_ID + timezoneOffset; - offset += timezoneOffset.length(); - } else if (timezoneIndicator == 'Z') { - timezoneId = GMT_ID; - offset += 1; - } else { - throw new IndexOutOfBoundsException("Invalid time zone indicator " + timezoneIndicator); - } - - TimeZone timezone = TimeZone.getTimeZone(timezoneId); - if (!timezone.getID().equals(timezoneId)) { - throw new IndexOutOfBoundsException(); - } - - 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 + "'"); - throw new ParseException("Failed to parse date [" + input + "]: " + fail.getMessage(), pos.getIndex()); - } - - /** - * 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); - } - result = -digit; - } - while (i < endIndex) { - digit = Character.digit(value.charAt(i++), 10); - if (digit < 0) { - throw new NumberFormatException("Invalid number: " + value); - } - result *= 10; - result -= digit; - } - return -result; - } -}