Writing to NFC tags on Android Chrome with LiveView

Eric Sullivan

Tagged as severus, nfc, liveview, elixir, invitations

Introduction

Severus recently upgraded invitations with “Advanced Settings”:

Digital Business Cards

By setting an invitation to “Public” and “Automatically Accept Responses”, you can use it as a digital business card. Similar products often sell NFC Cards. Some look really nice and they come in multiple form factors, but the approach taken by HiHello was interesting:

“Instead of purchasing an NFC business card, make your own”

I liked that idea. You can purchase stickers in bulk for under $0.30/Count, and cards aren’t much more expensive. However, without dedicated hardware or a mobile app I assumed Severus could not program them. Then I discovered Web NFC.

Web NFC

Web NFC allows Chrome Android to read and write NFC Tags. For Severus, this means we’ll write the invitation URL to a NFC Tag. Then, when it’s scanned by another Android device with NFC support it’ll open a browser to that invitation and allow the user to accept it. Here’s an example of what we’ll end up with:

NFC DEMO

Prior Art

The examples I found online tended to fall into proof-of-concept code that uses console.log instead of managing state. Here’s an example for Handling initial reads while writing. That’s important, because once you write to a tag the phone will automatically read the tag and try to open (which isn’t a great user experience). The trick is to be scanning while you write the NFC tag:

const ndef = new NDEFReader();
let ignoreRead = false;

ndef.onreading = (event) => {
  if (ignoreRead) {
    return; // write pending, ignore read.
  }

  console.log("We read a tag, but not during pending write!");
};

function write(data) {
  ignoreRead = true;
  return new Promise((resolve, reject) => {
    ndef.addEventListener("reading", event => {
      // Check if we want to write to this tag, or reject.
      ndef.write(data).then(resolve, reject).finally(() => ignoreRead = false);
    }, { once: true });
  });
}

await ndef.scan();
try {
  await write("Hello World");
  console.log("We wrote to a tag!")
} catch(err) {
  console.error("Something went wrong", err);
}

Phoenix Hooks

The example code above didn’t quite work on my Pixel 5. It would sporadically produce an odd error error

“DOMException: Failed to write due to an IO error: Call connect() first!”

Below are my modifications. Also, instead of the console this uses pushEvent for the client-server communication and delegates to LiveView for UI updates.

Hooks.NFC = {
  mounted() {
    if ('NDEFReader' in window) {
      this.pushEvent("nfc_enabled")

      this.handleEvent("write_nfc", async e => {
        if (this.ctlr) {
          this.ctlr.abort()
        }

        // NOTE: Absolute URL will open a browser, so it is not intended for client apps
        // https://w3c.github.io/web-nfc/#bib-ndef-uri
        const urlRecord = {
          recordType: "url",
          data: e.url
        };

        const ndef = new NDEFReader();
        ndef.onreading = (event) => console.log("NFC Read: ignore");

        this.ctlr = new AbortController();
        this.ctlr.signal.onabort = () => {};

        ndef.scan({ signal: this.ctlr.signal });

        await ndef.write(
          { records: [urlRecord] },
          { signal: this.ctlr.signal }
        ).then(() => {
          this.pushEvent("nfc_write_success");
        }).catch((err) => {
          if (err.name === "AbortError") {
            this.pushEvent("nfc_write_aborted", {error: err});
          } else {
            this.pushEvent("nfc_write_failure", {error: err});
          }
        }).finally(() => {
          setTimeout(() => this.ctlr.abort(), 3_000);
        })
      })

      this.handleEvent("cancel_nfc", e => {
        if (this.ctlr) {
          this.ctlr.abort()
        }
      })
    }
  }
}

Walking though this, we start by checking for Web NFC support and notify LiveView on success. Then, we handle the write_nfc event and start scanning while also ignoring any tags that are read. At the same time, we try to write, which will wait until a tag is read or the user cancels with the cancel_nfc event. This approach differs from the example in that we eventually want to stop reading, which is why we have the timeout. We give the user 3 seconds to remove the written tag from the device, and then we abort the scan, which will allow the OS to handle any future RFID tags.

Phoenix LiveView

In this setup, LiveView just routes messages between the UI elements and the Phoenix Hook. handle_event/3 is used to respond to the JS pushEvent, and liveview makes use of push_event/3 to send data back to the hook (which has its own handleEvent methods).

def mount(_params, _session, socket) do
  ...setup code...

  socket =
    socket
    |> assign(:nfc_enabled, false)

  {:ok, socket}
end)

def handle_event("nfc_enabled", _params, socket) do
  {:noreply,
   socket
   |> assign(:nfc_enabled, true)
   |> assign(:nfc_state, :initialized)}
end

def handle_event("write_nfc", _params, socket) do
  {:noreply,
   socket
   |> push_event("write_nfc", %{url: url})
   |> assign(:nfc_state, :write)}
end

def handle_event("nfc_write_success", _params, socket) do
  {:noreply,
   socket
   |> assign(:nfc_state, :success)}
end

def handle_event("nfc_write_failure", params, socket) do
  # record error for future analysis

  {:noreply,
   socket
   |> assign(:nfc_state, :error)}
end

def handle_event("nfc_write_aborted", _params, socket) do
  {:noreply,
   socket
   |> assign(:nfc_state, :initialized)}
end

def handle_event("cancel_nfc", _params, socket) do
  {:noreply,
   socket
   |> push_event("cancel_nfc", %{})}
end

The only noteworthy aspects are that we’re using LiveView to manage the nfc_state, and by sending all events through these function we can add logging or record metrics. In production I’m capturing errors in AppSignal for analysis.

Displaying State

Finally, the UI is only displayed if the client device supports Web NFC (by setting the nfc_enabled boolean in the mounted hook). I also removed the tailwind markup for clarity:

<div id="nfc_actions" phx-hook="NFC">
  <%= if @nfc_enabled do %>
    <div>
      <div>
        <h2>
          NFC Tag
        </h2>
        <p>
          Your device can write an NFC tag.
        </p>
      </div>

      <div>
        <div>
          <%= case @nfc_state do %>
          <% :initialized -> %>
            <%= link to: "#", phx_click: "write_nfc" do %>
              Write to NFC tag
            <% end %>
          <% :write -> %>
            <div>
              Hold device next to NFC tag
            </div>
            <%= link to: "#", phx_click: "cancel_nfc" do %>
              Cancel
            <% end %>
          <% :success -> %>
            <div>
              Success! NFC tag written
            </div>
            <%= link to: "#", phx_click: "write_nfc" do %>
              Write to NFC tag
            <% end %>
          <% :error -> %>
            <div>
              Failure: NFC tag could not be written
            </div>
            <%= link to: "#", phx_click: "write_nfc" do %>
              Write to NFC tag
            <% end %>
          <% end %>
        </div>
      </div>
    </div>
  <% end %>
</div>

Testing

As this is an Android feature, automated testing on my laptop was difficult. Luckily render_hook/3 allowed me to simulate the JS responses and verify the UI:

test "NFC - Success", %{conn: conn, user_invitation: user_invitation} do
  {:ok, view, _html} = live(conn, Routes.user_invitation_path(conn, :show, user_invitation))

  render_hook(view, "nfc_enabled")

  assert has_element?(view, "#nfc_actions", "NFC Tag")

  assert view |> element("a", "Write to NFC tag") |> render_click() =~
           "Hold device next to NFC tag"

  assert render_hook(view, "nfc_write_success") =~ "Success! NFC tag written"
end

test "NFC - Cancel", %{conn: conn, user_invitation: user_invitation} do
  {:ok, view, _html} = live(conn, Routes.user_invitation_path(conn, :show, user_invitation))

  render_hook(view, "nfc_enabled")

  assert has_element?(view, "#nfc_actions", "NFC Tag")

  assert view |> element("a", "Write to NFC tag") |> render_click() =~
           "Hold device next to NFC tag"

  assert view |> element("a", "Cancel") |> render_click() =~ "Hold device next to NFC tag"

  assert render_hook(view, "nfc_write_aborted") =~ "Write to NFC tag"
end

test "NFC - Error", %{conn: conn, user_invitation: user_invitation} do
  {:ok, view, _html} = live(conn, Routes.user_invitation_path(conn, :show, user_invitation))

  render_hook(view, "nfc_enabled")

  assert has_element?(view, "#nfc_actions", "NFC Tag")

  assert view |> element("a", "Write to NFC tag") |> render_click() =~
           "Hold device next to NFC tag"

  assert render_hook(view, "nfc_write_failure") =~ "Failure: NFC tag could not be written"
end

Development

Developing this feature was similarly tricky. I wanted to test this without deploying, but my laptop does not support Web NFC. Thankfully, you can connect your phone to your laptops using Remote Debugging. You’ll also need Android Debug Bridge / adb installed if you haven’t alread set your machine up for Android development.

Final Thoughts

I’m very pleased with the results. Unfortunately, as of 2020, apple has indicated they will not be supporting Web NFC. I plan to release a native mobile app in the future, so we’ll cover IOS then. For now, I hope this example inspires you to add NFC support to your Phoenix application. Thank you, and if you get a chance, please let me know how well the feature worked on your device (or if it failed spectacularly).

Discussion for this article can be found on Github.