269 lines
10 KiB
Kotlin
269 lines
10 KiB
Kotlin
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
|
||
}
|