1. Introduction

In this article, we’ll examine Okio and explain what it is, what we can do with it, and how to use it.

Okio is a library from Square, the authors of OkHttp. It expands the standard IO functionality within the JVM.

2. Dependencies

Before using Okio, we need to include the latest version in our build, which is 3.9.0 at the time of writing.

If we’re using Maven, we can include this dependency:

<dependency>
    <groupId>com.squareup.okio</groupId>
    <artifactId>okio</artifactId>
    <version>3.9.0</version>
</dependency>

Or if we’re using Gradle, we can include it like this:

implementation("com.squareup.okio:okio:3.9.0")

At this point, we’re ready to start using it in our application.

3. ByteStrings and Buffers

Two fundamental data types that Okio provides are ByteString and Buffer. These represent a sequence of bytes and allow us to interact with them as appropriate.

3.1. ByteString

A ByteString represents an immutable sequence of bytes. We can think of it as similar to String, only for bytes instead of characters.

We can construct these directly by using the ByteString.of() companion method. This takes a varargs array of Byte values:

val byteString = ByteString.of(1, 2);

Alternatively, Okio provides extension functions on several other types to directly convert them into ByteString objects:

// From a simple ByteArray.
val byteString = byteArray.toByteString()

// From a java.nio.ByteBuffer.
val byteString = byteBuffer.toByteString()

// From a java.io.InputStream.
val byteString = inputStream.readByteString(5)

// Converting a String into bytes in a given character set.
val byteString = "Hello".encodeUtf8()
val byteString = "Hello".encode(Charsets.UTF_32)

We’re also able to convert a ByteString into other types in a very similar manner:

// To a ByteArray.
val byteArray = byteString.toByteArray()

// To a java.nio.ByteBuffer.
val byteBuffer = byteString.asByteBuffer()

// Converting into a String, treating the bytes as the given character set.
val utf8String = byteString.utf8()
val string = byteString.string(Charsets.UTF_8)

// Writing to an OutputStream.
byteString.write(outputStream)

Once we’ve obtained a ByteString we can then access the data from it. This includes things such as querying the size of it, or directly accessing individual bytes:

var size = byteString.size
val firstByte = byteString[0]

Okio also offers methods to support various string-safe encodings, such as to and from Base64 or Hex encodings:

val byteString = "SGVsbG8=".decodeBase64()
val string = byteString.base64()

val byteString = "48656c6c6f".decodeHex()
val string = byteString.hex()

We can also do operations such as cryptographic hashes on the bytes. Note that these typically produce another ByteString as a result, allowing us to encode the output however we need:

byteString.md5().hex()
byteString.sha1().base64()

3.2. Buffer

A Buffer represents a mutable sequence of bytes, giving us methods to easily read from and write to the buffer.

Unlike ByteStrings, a Buffer is always constructed to be empty:

val buffer = Buffer()

We then have various methods that allow us to write data into the Buffer, depending on exactly what the data is:

// Write the entire ByteArray into the Buffer.
buffer.write(byteArray)
// Write the entire ByteString into the Buffer.
buffer.write(byteString)
// Write the next 10 bytes from one Buffer into another.
buffer.write(buffer, 10)

// Write a single byte into the Buffer.
buffer.writeByte(b)
// Write an integer into the Buffer, as big endian bytes.
buffer.writeInt(i)
// Write an integer into the Buffer, as little endian bytes.
buffer.writeIntLe(i)

// Write a string into the Buffer, as UTF-8 bytes.
buffer.writeUtf8(string)

// Read the next 10 bytes from the InputStream and write them into the Buffer.
buffer.readFrom(inputStream, 10)

We also have the opposite set of methods, allowing us to read data out of the Buffer:

// Read the entire Buffer into a ByteArray.
val byteArray = buffer.readByteArray()
// Read the entire Buffer into a ByteString.
val byteString = buffer.readByteString()

// Read a single byte from the Buffer.
val b = buffer.readByte()
// Read a single integer from the buffer, as big endian bytes.
val i = buffer.readInt()
// Read a single integer from the buffer,as little endian bytes.
val ile = buffer.readIntLe()

// Read the entire Buffer as a String encoded as UTF-8 bytes.
val s = buffer.readUtf8()

// Write the next 10 bytes of the Buffer to the given OutputStream.
buffer.writeTo(os, 10)

Our buffer keeps track of the last point that we read data from, allowing us to keep reading data from the buffer as appropriate:

val length = buffer.readInt()
val string = buffer.readUtf8(length)

4. Sources and Sinks

Where ByteString and Buffer instances represent data at rest, Okio also has the concept of Source and Sink to represent data in motion.

4.1. Sink

A Sink is anything that we can use to send data to. We do this by passing our data – in the form of a Buffer – to the Sink.write() method. Doing this will write the desired number of bytes from this Buffer to whatever destination the Sink represents:

val buffer = Buffer()
// populate the buffer

val sink = buildSink()
sink.write(buffer, 10)

Sink itself is an interface with a number of standard implementations depending on exactly what we want to achieve.

In order to interact between Okio and standard Java IO, one of the standard implementations that we’re provided with is an OutputStreamSink. This wraps any java.io.OutputStream and lets us use it as if it were an Okio Sink.

We can construct one of these using the OutputStream.sink() extension method, and then use it as any other Sink:

val sink = outputStream.sink()
sink.write(inputBuffer, inputBuffer.size)

A Buffer is another one of the core implementations of Sink. This means that we can use a Buffer as a target for writing any data, exactly as if it were any other Sink:

val target = Buffer()
target.write(inputBuffer, inputBuffer.size)

We can also use a BufferedSink to make it easier to work with. This wraps our Sink with a Buffer and gives us access to all the same write methods that we have from Buffer, allowing us to write a variety of different types easily:

val sink = createSink()
val bufferedSink = sink.buffer()
bufferedSink.writeUtf8("Hello, World!")
bufferedSink.flush()

In this case, we need to call flush() or close() on the BufferedSink in order to flush data from it into the underlying Sink.

We can also have Sink implementations that augment other Sinks. For example, we can use the GzipSink to compress our data as it flows through, or the CipherSink to encrypt our data:

val gzipSink = GzipSink(targetSink)
gzipSink.write(inputBuffer, inputBuffer.size)
gzipSink.flush()

Again, we’re required to either flush() or close() our sink at the end. This is because GZip works with blocks of data, and needs to know when it can process the next block.

4.2. Source

In the same way that a Sink is something that we can use to send data to, a Source is anything that we can read data from. We can do this using the Source.read() method, passing in a Buffer and the amount of data to read:

val source = buildSource()
val buffer = Buffer()
val bytesRead = source.read(buffer, 10)

This will then write up to this many bytes from our Source into the Buffer, and return the number of bytes that were actually read.

In the same way that we have a BufferedSink to make working with Sink instances easier, we also have a BufferedSource to make working with Source instances easier. In this case, it will allow us to read values from our Source, and will block until the data is entirely available:

val source = createSource()
val bufferedSource = source.buffer()
val string = bufferedSource.readUtf8()

We also have an InputStreamSource that we can use to wrap a java.io.InputStream that we obtained from anywhere else, allowing us to easily integrate with standard Java IO:

val source = inputStream.source()

We can also treat any Buffer directly as a Source, in exactly the same way that we could treat it as a Sink. This is especially useful since it means that we can use a single Buffer instance as both a Sink and Source at the same time if we ever need to:

val buffer = Buffer()
input.writeToSink(buffer)
// Do something with the data in the Buffer.
output.readFromSource(buffer)

We also have access to Source implementations that exist to augment other Source instances, in exactly the same way that we can with Sinks. For example, the GzipSource will allow us to decompress data as it flows through:

val gzipSource = GzipSource(source)
val decompressed = gzipSource.buffer().readUtf8()

5. FileSystem

Okio provides abstractions to represent data values, data sources, and data sinks, as well as entire filesystems.

In order to access the underlying filesystem of the computer, we can use the FileSystem.SYSTEM constant. This gives us access to the files on the computer, starting from the current working directory. We can then interact with these files however we need – listing them, creating new ones, deleting existing ones, and even reading and writing them:

val files = FileSystem.SYSTEM.list(".".toPath())

Reading and writing files is particularly easy with Okio since it makes use of the Sink and Source concepts that we’ve already covered. For example, we can use the FileSystem to provide us with a Source that is backed by a real file on disk:

val fileSource = FileSystem.SYSTEM.source("README.md".toPath())
val fileSink = FileSystem.SYSTEM.sink("target".toPath())

At this point, we can treat it the same as any other Source or Sink instance.

6. Conclusion

Here’s a quick introduction to Okio. This library can do much more, so why not try it out and see?

All of the examples are available over on GitHub.