Having fun using java.time
and Retrofit for ISO 8601 dates on Android
I was assigned a task to parse ISO 8601 dates with timezone information from an endpoint. I decided to use Java 8’s java.time
library. I had to write a custom converter to use it with Retrofit because the endpoint provided the date in a different format. This is a summary of my journey.
What is java.time
, and how do I use it with Android?
java.time
aims to improve the date and time handling in Java. It was added to Java in JSR-310. It was included in Android as of API 26. But if you want to use it with earlier versions, you must use a library.
There are a couple of solutions, each with their own drawbacks. I settled on a library by Jake Wharton called ThreeTenABP. It has been optimized for the Android platform. Jake goes into his reasoning on the project’s main page. https://github.com/JakeWharton/ThreeTenABP
To get this to work with Retrofit, I used a library called ThreeTen-Backport-Gson-Adapter. It’s a library that provides json serialization/deserialization for java.time
using Gson
. https://github.com/aaronhe42/ThreeTen-Backport-Gson-Adapter
Let’s run some tests!
There are lots of ways to document your code. One of my favorites is with tests. A good test can express how to do something, and why you are doing it. It also allows a future developer to play around with the code in a safe environment.
All my examples are tests. I’ve included the classes under test with the test itself. It makes it easier to show here.
Make sure you click to expand the test, otherwise you can’t see it!
Setup environment
You can set these up in Android Studio, but you can also use IntelliJ Community Edition.
I like having IntelliJ around to run test code. Create a new Project with Gradle, and include Java and Kotlin. Then use the build.gradle
file to include your dependencies just like you would in Android Studio.
My dependencies are below. Keep in mind that these will be old versions by the time you read this.
Click to see codedependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
testCompile group: 'junit', name: 'junit', version: '4.12'
/* Java 8 java.time (JSR-310) backport for Android */
implementation 'com.jakewharton.threetenabp:threetenabp:1.2.0'
implementation("org.aaronhe:threetenbp-gson-adapter:1.0.2") {
exclude module: 'threetenbp'
}
testImplementation 'org.threeten:threetenbp:1.3.8'
}
java.time tests
I wrote some tests to see how java.time behaves. Since it’s a newer library, I figured I could encourage my team to use them to learn java.time
in a playground-like environment.
import junit.framework.Assert.assertEquals
import org.junit.Test
import org.threeten.bp.LocalDateTime
import org.threeten.bp.ZoneId
import org.threeten.bp.ZoneOffset
import org.threeten.bp.ZonedDateTime
import org.threeten.bp.format.DateTimeFormatter
class JavaTimeTests {
@Test
fun test_basicParsing() {
// GIVEN a date at 12:25am UTC
val apiDate = LocalDateTime.parse("2019-01-18T00:25:00.0000000")
val zonedDate = ZonedDateTime.of(apiDate, ZoneId.of("UTC"))
// WHEN I convert my date to PST
val zoneID = ZoneId.of("PST", ZoneId.SHORT_IDS)
val inMyTimezone = zonedDate.withZoneSameInstant(zoneID)
val isoDateTime = inMyTimezone.format(DateTimeFormatter.ISO_DATE_TIME)
// THEN I get the expected date and time in PST (a day earlier)
val isoDate = inMyTimezone.format(DateTimeFormatter.ISO_DATE)
assertEquals("2019-01-17-08:00", isoDate.toString())
// WHEN I format my date in a different pattern (using slashes)
val patternDate = inMyTimezone.format(DateTimeFormatter.ofPattern("yyyy/MM/dd"))
// THEN I receive a date use the expected pattern
assertEquals("2019/01/17", patternDate.toString())
// WHEN I format my date in a different patter (using dashes)
val localDate = inMyTimezone.toLocalDate()
// THEN I receive a date use the expected pattern
assertEquals("2019-01-17", localDate.toString())
}
@Test
fun test_convertDatesToUTC() {
// GIVEN a string date and string timezone
val usZone = ZoneId.of("America/Los_Angeles")
val str = "1926-09-23 00:00"
// WHEN I parse the string date to a ZonedDateTime
val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
val localDateAndTime = LocalDateTime.parse(str, formatter)
val dateAndTimeInUS = ZonedDateTime.of(localDateAndTime, usZone)
// THEN I get the expected date in PST
// 0 AM PST
assertEquals(0, dateAndTimeInUS.hour)
// THEN I get the expected date in UTC
// 8 AM UTC
val utcDate = dateAndTimeInUS.withZoneSameInstant(ZoneOffset.UTC)
assertEquals(8, utcDate.hour)
}
}
Parsing dates with gson
I’ve included the smallest amount of code to see how to use gson to format the custom date.
- I don’t show how to use retrofit here, I just use a gson builder to incorporate the converter factory
- The Converter parses a custom shaped json object, noted in the test
// for EndpointFactory
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import org.aaronhe.threetengson.ThreeTenGsonAdapter
// for EndpointDate
import com.google.gson.JsonDeserializationContext
import com.google.gson.JsonDeserializer
import com.google.gson.JsonElement
import com.google.gson.JsonParseException
import org.junit.Assert.assertEquals
import org.junit.Test
import org.threeten.bp.LocalDateTime
import org.threeten.bp.Month
import org.threeten.bp.ZoneId
import org.threeten.bp.ZonedDateTime
import org.threeten.bp.format.DateTimeParseException
import java.lang.reflect.Type
/**
* The data class for the date
*/
data class EndpointDate(
val dateTime: ZonedDateTime,
val timeZone: String
)
/**
* Deserialize an EndpointDate for Gson. This will load the proper ZonedDateTime, in the given timeZone
*/
class EndpointDateDeserializer : JsonDeserializer<EndpointDate> {
override fun deserialize(json: JsonElement?, typeOfT: Type?, context: JsonDeserializationContext?): EndpointDate {
json?.let {
val jsonObject = it.asJsonObject
try {
val apiDate = LocalDateTime.parse(jsonObject.get("dateTime").asString)
val zoneString = jsonObject.get("timeZone").asString
val zone = ZoneId.of(zoneString)
val zonedDate = ZonedDateTime.of(apiDate, zone)
return EndpointDate(zonedDate, zoneString)
} catch (e: DateTimeParseException) {
throw JsonParseException(e)
}
}
throw IllegalArgumentException("unknown type: $typeOfT")
}
}
/**
* I just supply the gson builder
*/
object EndpointFactory {
fun getGson(): Gson {
val gsonBuilder = GsonBuilder()
gsonBuilder.registerTypeAdapter(EndpointDate::class.java, EndpointDateDeserializer())
return ThreeTenGsonAdapter.registerZonedDateTime(gsonBuilder).create()
}
}
class DateParseTests {
@Test
fun test_parseEndpointDate() {
// GIVEN a gson object with a deserializer that can parse EndpointDate objects
val gson = EndpointFactory.getGson()
// GIVEN json of an EndpointDate
val json = """
{
"dateTime": "1564-04-23T11:25:00.0000000",
"timeZone": "UTC"
}
""".trimIndent()
// WHEN the json is parsed into a EndpointDate data class
val endpointDate = gson.fromJson(json, EndpointDate::class.java)
// THEN I get the expected date with proper time zone
assertEquals(1564, endpointDate.dateTime.year)
assertEquals(Month.APRIL, endpointDate.dateTime.month)
assertEquals(23, endpointDate.dateTime.dayOfMonth)
assertEquals(11, endpointDate.dateTime.hour)
assertEquals(25, endpointDate.dateTime.minute)
assertEquals("UTC", endpointDate.dateTime.zone.id)
}
}
Conclusion
This was an interesting problem to solve. We had some weird timezone issues that kept popping up. Our app was going to be heavily reliant on time. I knew we needed to stop writing short-term hacks and come up with something more concrete. This process took a little more time to put in place, but it paid off in the end by clearing the road ahead of us.