lib/pigeon/apns.ex

defmodule Pigeon.APNS do
  @moduledoc """
  `Pigeon.Adapter` for Apple Push Notification Service (APNS) push notifications.

  ## Getting Started

  1. Create an `APNS` dispatcher.

  ```
  # lib/apns.ex
  defmodule YourApp.APNS do
    use Pigeon.Dispatcher, otp_app: :your_app
  end
  ```

  2. (Optional) Add configuration to your `config.exs`.

  ```
  # config.exs

  config :your_app, YourApp.APNS,
    adapter: Pigeon.APNS,
    cert: File.read!("cert.pem"),
    key: File.read!("key_unencrypted.pem"),
    mode: :dev
  ```

  Or use token based authentication:

  ```
  config :your_app, YourApp.APNS,
    adapter: Pigeon.APNS,
    key: File.read!("AuthKey.p8"),
    key_identifier: "ABC1234567",
    mode: :dev,
    team_id: "DEF8901234"
  ```

  3. Start your dispatcher on application boot.

  ```
  defmodule YourApp.Application do
    @moduledoc false

    use Application

    @doc false
    def start(_type, _args) do
      children = [
        YourApp.APNS
      ]
      opts = [strategy: :one_for_one, name: YourApp.Supervisor]
      Supervisor.start_link(children, opts)
    end
  end
  ```

  If you skipped step two, include your configuration.

  ```
  defmodule YourApp.Application do
    @moduledoc false

    use Application

    @doc false
    def start(_type, _args) do
      children = [
        {YourApp.APNS, apns_opts()}
      ]
      opts = [strategy: :one_for_one, name: YourApp.Supervisor]
      Supervisor.start_link(children, opts)
    end

    defp apns_opts do
      [
        adapter: Pigeon.APNS,
        cert: File.read!("cert.pem"),
        key: File.read!("key_unencrypted.pem"),
        mode: :dev
      ]
    end
  end
  ```

  4. Create a notification. **Note: Your push topic is generally the app's bundle identifier.**

  ```
  n = Pigeon.APNS.Notification.new("your message", "your device token", "your push topic")
  ```
   
  5. Send the packet. Pushes are synchronous and return the notification with an
   updated `:response` key.

  ```
  YourApp.APNS.push(n)
  ```

  ## Configuration Options

  #### Certificate Authentication

  - `:cert` - Push certificate. Must be the full-text string of the file contents.
  - `:key` - Push private key. Must be the full-text string of the file contents.

  #### Token Authentication

  - `:key` - JWT private key. Must be the full-text string of the file contents.
  - `:key_identifier` - A 10-character key identifier (kid) key, obtained from
    your developer account.
  - `:team_id` - Your 10-character Team ID, obtained from your developer account.

  #### Shared Options

  - `:mode` - If set to `:dev` or `:prod`, will set the appropriate `:uri`.
  - `:ping_period` - Interval between server pings. Necessary to keep long
    running APNS connections alive. Defaults to 10 minutes.
  - `:port` - Push server port. Can be any value, but APNS only accepts
    `443` and `2197`.
  - `:uri` - Push server uri. If set, overrides uri defined by `:mode`.
    Useful for test environments.

  ## Generating Your Certificate and Key .pem

  1. In Keychain Access, right-click your push certificate and select _"Export..."_
  2. Export the certificate as `cert.p12`
  3. Click the dropdown arrow next to the certificate, right-click the private
     key and select _"Export..."_
  4. Export the private key as `key.p12`
  5. From a shell, convert the certificate.

  ```
  openssl pkcs12 -clcerts -nokeys -out cert.pem -in cert.p12
  ```

  6. Convert the key. Be sure to set a PEM pass phrase here. The pass phrase must be 4 or 
     more characters in length or this will not work. You will need that pass phrase added 
     here in order to remove it in the next step.

  ```
  openssl pkcs12 -nocerts -out key.pem -in key.p12
  ```

  7. Remove the PEM pass phrase from the key.

  ```
  openssl rsa -in key.pem -out key_unencrypted.pem
  ```

  8. `cert.pem` and `key_unencrypted.pem` can now be used in your configuration.
  """

  defstruct queue: Pigeon.NotificationQueue.new(),
            stream_id: 1,
            socket: nil,
            config: nil

  @behaviour Pigeon.Adapter

  alias Pigeon.{Configurable, NotificationQueue}
  alias Pigeon.APNS.ConfigParser
  alias Pigeon.Http2.{Client, Stream}

  @impl true
  def init(opts) do
    config = ConfigParser.parse(opts)
    Configurable.validate!(config)

    state = %__MODULE__{config: config}

    case connect_socket(config) do
      {:ok, socket} ->
        Configurable.schedule_ping(config)
        {:ok, %{state | socket: socket}}

      {:error, reason} ->
        {:stop, reason}
    end
  end

  @impl true
  def handle_push(notification, %{config: config, queue: queue} = state) do
    headers = Configurable.push_headers(config, notification, [])
    payload = Configurable.push_payload(config, notification, [])

    Client.default().send_request(state.socket, headers, payload)

    new_q = NotificationQueue.add(queue, state.stream_id, notification)

    state =
      state
      |> inc_stream_id()
      |> Map.put(:queue, new_q)

    {:noreply, state}
  end

  def handle_info(:ping, state) do
    Client.default().send_ping(state.socket)
    Configurable.schedule_ping(state.config)

    {:noreply, state}
  end

  def handle_info({:closed, _}, %{config: config} = state) do
    case connect_socket(config) do
      {:ok, socket} ->
        Configurable.schedule_ping(config)

        state =
          state
          |> reset_stream_id()
          |> Map.put(:socket, socket)

        {:noreply, state}

      {:error, reason} ->
        {:stop, reason}
    end
  end

  @impl true
  def handle_info(msg, state) do
    case Client.default().handle_end_stream(msg, state) do
      {:ok, %Stream{} = stream} -> process_end_stream(stream, state)
      _else -> {:noreply, state}
    end
  end

  defp connect_socket(config), do: connect_socket(config, 0)

  defp connect_socket(_config, 3), do: {:error, :timeout}

  defp connect_socket(config, tries) do
    case Configurable.connect(config) do
      {:ok, socket} -> {:ok, socket}
      {:error, _reason} -> connect_socket(config, tries + 1)
    end
  end

  @doc false
  def process_end_stream(%Stream{id: stream_id} = stream, state) do
    %{queue: queue, config: config} = state

    case NotificationQueue.pop(queue, stream_id) do
      {nil, new_queue} ->
        # Do nothing if no queued item for stream
        {:noreply, %{state | queue: new_queue}}

      {notif, new_queue} ->
        Configurable.handle_end_stream(config, stream, notif)
        {:noreply, %{state | queue: new_queue}}
    end
  end

  @doc false
  def inc_stream_id(%{stream_id: stream_id} = state) do
    %{state | stream_id: stream_id + 2}
  end

  @doc false
  def reset_stream_id(state) do
    %{state | stream_id: 1}
  end
end