defmodule AirPlay.V2.Plist do
@moduledoc """
Minimal binary plist writer for AirPlay 2 SETUP bodies.
Supports the primitive shapes AP2 setup needs: maps with string keys, lists,
binaries, strings, booleans, floats and non-negative integers.
"""
import Bitwise, only: [&&&: 2, <<<: 2, |||: 2, >>>: 2]
@spec encode!(term()) :: binary()
def encode!(value) do
{root_ref, objects} = flatten(value, [])
object_count = length(objects)
ref_size = int_size(max(object_count - 1, 0))
object_table =
objects
|> Enum.reverse()
|> Enum.map(&encode_object(&1, ref_size))
object_table = IO.iodata_to_binary(object_table)
offsets = offsets_from_lengths(Enum.reverse(objects), 8, ref_size)
offset_table_offset = 8 + byte_size(object_table)
offset_size = int_size(offset_table_offset)
offset_table =
offsets
|> Enum.map(&uint(&1, offset_size))
|> IO.iodata_to_binary()
trailer =
<<0::48, offset_size, ref_size, object_count::64, root_ref::64, offset_table_offset::64>>
"bplist00" <> object_table <> offset_table <> trailer
end
@doc "Encode a term as a binary plist."
@spec encode(term()) :: binary()
def encode(value), do: encode!(value)
@doc "Decode the subset of binary plist values emitted by AirPlay receivers."
@spec decode!(binary()) :: term()
def decode!(<<"bplist00", _rest::binary>> = data) do
size = byte_size(data)
trailer = binary_part(data, size - 32, 32)
<<0::48, offset_size, ref_size, object_count::64, root_ref::64, offset_table_offset::64>> =
trailer
offsets =
for index <- 0..(object_count - 1) do
offset_table_offset
|> Kernel.+(index * offset_size)
|> then(&binary_part(data, &1, offset_size))
|> read_uint()
end
{value, _cache} = decode_ref(data, offsets, ref_size, root_ref, %{})
value
end
def decode!(data) when is_binary(data), do: raise(ArgumentError, "not a binary plist")
@doc "Decode a binary plist into `{:ok, term}` or `{:error, reason}`."
@spec decode(binary()) :: {:ok, term()} | {:error, term()}
def decode(<<"bplist00", _rest::binary>> = data), do: {:ok, decode!(data)}
def decode(data) when is_binary(data), do: {:error, :not_bplist}
defp decode_ref(_data, _offsets, _ref_size, ref, cache) when is_map_key(cache, ref) do
{Map.fetch!(cache, ref), cache}
end
defp decode_ref(data, offsets, ref_size, ref, cache) do
offset = Enum.at(offsets, ref)
marker = :binary.at(data, offset)
type = marker >>> 4
info = marker &&& 0x0F
{value, cache} = decode_typed_value(type, info, data, offset, offsets, ref_size, cache)
{value, Map.put(cache, ref, value)}
end
defp decode_typed_value(0x0, info, _data, _offset, _offsets, _ref_size, cache) do
{simple_value(info), cache}
end
defp decode_typed_value(0x1, info, data, offset, _offsets, _ref_size, cache) do
size = 1 <<< info
{data |> binary_part(offset + 1, size) |> read_int(size), cache}
end
defp decode_typed_value(0x2, info, data, offset, _offsets, _ref_size, cache) do
size = 1 <<< info
value =
case binary_part(data, offset + 1, size) do
<<float::float-big-32>> -> float
<<float::float-big-64>> -> float
end
{value, cache}
end
defp decode_typed_value(0x4, info, data, offset, _offsets, _ref_size, cache) do
{count, value_offset} = decode_count(data, offset, info)
{{:data, binary_part(data, value_offset, count)}, cache}
end
defp decode_typed_value(0x5, info, data, offset, _offsets, _ref_size, cache) do
{count, value_offset} = decode_count(data, offset, info)
{binary_part(data, value_offset, count), cache}
end
defp decode_typed_value(0x6, info, data, offset, _offsets, _ref_size, cache) do
{count, value_offset} = decode_count(data, offset, info)
value =
data
|> binary_part(value_offset, count * 2)
|> :unicode.characters_to_binary({:utf16, :big}, :utf8)
{value, cache}
end
defp decode_typed_value(0xA, info, data, offset, offsets, ref_size, cache) do
{count, refs_offset} = decode_count(data, offset, info)
refs = read_refs(data, refs_offset, ref_size, count)
decode_refs(refs, data, offsets, ref_size, cache)
end
defp decode_typed_value(0xD, info, data, offset, offsets, ref_size, cache) do
{count, refs_offset} = decode_count(data, offset, info)
key_refs = read_refs(data, refs_offset, ref_size, count)
value_refs = read_refs(data, refs_offset + count * ref_size, ref_size, count)
{keys, cache} = decode_refs(key_refs, data, offsets, ref_size, cache)
{values, cache} = decode_refs(value_refs, data, offsets, ref_size, cache)
{keys |> Enum.zip(values) |> Map.new(), cache}
end
defp decode_refs(refs, data, offsets, ref_size, cache) do
Enum.map_reduce(refs, cache, fn child_ref, cache ->
decode_ref(data, offsets, ref_size, child_ref, cache)
end)
end
defp simple_value(0x0), do: nil
defp simple_value(0x8), do: false
defp simple_value(0x9), do: true
defp decode_count(_data, offset, info) when info < 0xF, do: {info, offset + 1}
defp decode_count(data, offset, 0xF) do
int_offset = offset + 1
marker = :binary.at(data, int_offset)
0x1 = marker >>> 4
size = 1 <<< (marker &&& 0x0F)
count = data |> binary_part(int_offset + 1, size) |> read_uint()
{count, int_offset + 1 + size}
end
defp read_refs(_data, _offset, _ref_size, 0), do: []
defp read_refs(data, offset, ref_size, count) do
for index <- 0..(count - 1) do
data
|> binary_part(offset + index * ref_size, ref_size)
|> read_uint()
end
end
defp flatten(value, objects) when is_map(value) do
pairs = value |> Enum.sort_by(fn {key, _value} -> to_string(key) end)
{key_refs, objects} =
Enum.map_reduce(pairs, objects, fn {key, _value}, acc -> flatten(to_string(key), acc) end)
{value_refs, objects} =
Enum.map_reduce(pairs, objects, fn {_key, value}, acc -> flatten(value, acc) end)
add_object({:dict, key_refs, value_refs}, objects)
end
defp flatten(value, objects) when is_list(value) do
{refs, objects} = Enum.map_reduce(value, objects, &flatten/2)
add_object({:array, refs}, objects)
end
defp flatten({:data, value}, objects) when is_binary(value),
do: add_object({:data, value}, objects)
defp flatten(value, objects) when is_binary(value) do
if String.valid?(value),
do: add_object({:string, value}, objects),
else: add_object({:data, value}, objects)
end
defp flatten(value, objects) when is_boolean(value), do: add_object({:bool, value}, objects)
defp flatten(value, objects) when is_float(value), do: add_object({:real, value}, objects)
defp flatten(value, objects) when is_integer(value),
do: add_object({:int, value}, objects)
defp add_object(object, objects), do: {length(objects), [object | objects]}
defp encode_object({:bool, false}, _ref_size), do: <<0x08>>
defp encode_object({:bool, true}, _ref_size), do: <<0x09>>
defp encode_object({:int, value}, _ref_size), do: encode_int(value)
defp encode_object({:real, value}, _ref_size), do: encode_real(value)
defp encode_object({:data, value}, _ref_size), do: [marker(0x40, byte_size(value)), value]
defp encode_object({:string, value}, _ref_size), do: [marker(0x50, byte_size(value)), value]
defp encode_object({:array, refs}, ref_size) do
[marker(0xA0, length(refs)), Enum.map(refs, &uint(&1, ref_size))]
end
defp encode_object({:dict, key_refs, value_refs}, ref_size) do
[
marker(0xD0, length(key_refs)),
Enum.map(key_refs, &uint(&1, ref_size)),
Enum.map(value_refs, &uint(&1, ref_size))
]
end
defp encode_int(value) do
size = int_size(value)
exponent = round(:math.log2(size))
[<<0x10 ||| exponent>>, int(value, size)]
end
defp encode_real(value), do: <<0x23, value::float-big-size(64)>>
defp marker(base, length) when length < 15, do: <<base ||| length>>
defp marker(base, length), do: [<<base ||| 0x0F>>, encode_int(length)]
defp offsets_from_lengths(objects, start, ref_size) do
{_offset, offsets} =
objects
|> Enum.map(&encode_object(&1, ref_size))
|> Enum.map(&IO.iodata_to_binary/1)
|> Enum.reduce({start, []}, fn encoded, {offset, acc} ->
{offset + byte_size(encoded), [offset | acc]}
end)
Enum.reverse(offsets)
end
defp int_size(value) when value < 0, do: 8
defp int_size(value) when value <= 0xFF, do: 1
defp int_size(value) when value <= 0xFFFF, do: 2
defp int_size(value) when value <= 0xFFFFFFFF, do: 4
defp int_size(_value), do: 8
defp int(value, size) when value < 0, do: <<value::signed-big-integer-size(size * 8)>>
defp int(value, size), do: uint(value, size)
defp uint(value, 1), do: <<value::8>>
defp uint(value, 2), do: <<value::16>>
defp uint(value, 4), do: <<value::32>>
defp uint(value, 8), do: <<value::64>>
defp read_int(binary, 8), do: binary |> read_signed_64()
defp read_int(binary, _size), do: read_uint(binary)
defp read_signed_64(<<value::signed-big-64>>), do: value
defp read_uint(binary), do: :binary.decode_unsigned(binary)
end