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 }