Back to Personal Projects

Tile Local: the build story

Actively managed: started October 2026. Updated October 2026.

This write-up covers Tile Local as of October 23, 2026. I'm still actively maintaining and improving it, and I'll keep this page updated as the work continues.

What Tile Local is, and why it exists

Tile Local is an Android app that talks directly to my own Tile trackers over Bluetooth. It does two things. It rings a Tile from my phone, and it rings my phone when I press the button on a Tile. Once it is set up, nothing it does touches the Tile or Life360 cloud.

I built it for privacy. I wanted control of my own trackers on my own phone, without a company's servers in the loop at runtime. The scope is deliberately narrow: the app only ever engages the Tiles listed in a file of my own, and it ignores every other Bluetooth device it sees. Anything involving other people's tags, locating unknown tags, or spoofing is out of scope.

The hardware

Everything here is mine. The phone is a Google Pixel 9 Pro XL running Android 16. The trackers are two 2020 Tiles: a Tile Sticker and a Tile Slim, the wallet one. In the app I call them Keys and Wallet. The Tile cloud lists their product codes as PROTEUS1 and WHIPPET1, which is how I know the exact models. Both are older than the 2022 and 2024 Tiles that the public reference code I leaned on was tested against, so firmware differences were a risk from day one.

The starting point

I went in with one assumption: that it would work. The technical starting spec was written by Claude, and several things in it turned out to be wrong. I think that is worth telling plainly, because the corrections are the most useful part of the story.

Checking the spec against the sources

On the first day, Claude checked its own starting spec against published research and an open-source reference implementation. The research is a 2025 security and privacy analysis of Tile's protocol, and the reference code is a community project called node-tile. Five things in the spec did not hold up. These were hypotheses the AI proposed, and the sources disproved them:

Note what that was: a paper-and-code check done before any hardware was involved. Real hardware came later, and it had its own corrections for me.

The design that came out of it

Kotlin moves bytes and handles the Android side. Python makes every decision. A foreground service keeps a connection to each Tile. Android's Bluetooth callback class is abstract, which Chaquopy (the tool that runs Python inside an Android app) cannot subclass from Python, so Kotlin reports events through a small interface that Python implements. That choice comes back later.

The protocol works like this, with no secret values shown. The phone connects to the FEED service and turns on notifications. It sends an AUTHENTICATE command carrying a random 14-byte number, and the Tile answers with a 10-byte random number of its own and a 4-byte response. The phone then sends OPEN_CHANNEL, and the Tile returns a channel ID and some channel data. From those, the phone derives a per-session key by running HMAC-SHA256 over the Tile's auth key, the random number, the channel data, the channel ID and a token, and keeping the first 16 bytes. After that every command is framed as channel ID, command, payload, and a 4-byte check value taken from an HMAC over a counter, a type byte, the length and the message. The phone sends READY, and the Tile replies with its maximum payload size and a feature bitfield. From there the channel is live: SONG rings the Tile, and TDT notifications report button presses.

The first session also produced the Python core and 27 desktop tests. The integration test caught a real wiring bug on the first day: the debouncer was handing the alert handler its own key instead of the Tile object. A second session the same day added a zip of the whole project and a single-file copy of the source that I could read on my phone.

What happened on the way to ringing

Setting up, and the first dead end: getting the key from a browser

I put the project in a private Git repository, with a .gitignore that keeps the file holding my Tiles' keys out of version control from the start. Python tests passed, 27 of 27.

Each Tile has an auth key, a per-device secret that proves to the Tile that the phone is allowed to talk to it. The key lives in my own Tile account, so the first idea was to read it out of a signed-in browser session. That failed. The Tile website redirects to Life360's, which only shows shop and marketing pages. No web page I could reach shows the Tile list at all, and no Tile or API requests came from those pages. The list appears to exist only in the Life360 phone app and its API. I did not log in, guess at account URLs, or replay any requests.

The research that followed found that every public library logs in with an email and a password. None of them documented a one-time-email-code flow, and none could be confirmed as working in 2026. The recommendation was to run my own small script on my own PC, read the password without leaving it in shell history, and write the keys straight to a file that Git ignores. I also ruled out rooting the phone and modifying the Tile app.

The second dead end: reading the app's traffic

Before trusting the script route, I tested whether the Life360 app's own connections could be read, by putting a proxy between my phone and the internet and installing the proxy's certificate on the phone. This was only a feasibility test on my own phone and my own account, not a capture of a login.

It was refused. All 207 connections to Life360 hosts that the test logged ended in a refused TLS handshake. Nothing was decrypted. As a control, I loaded my own website through the same proxy and it decrypted fine, so the certificate was installed correctly and the refusal came from the app itself, which is consistent with certificate pinning. Getting past that would take a rooted phone or a patched app, and I had ruled both out. The route was closed. One of the automated steps in this test was stopped by a safety safeguard, so I did that step myself rather than retrying. Afterward I deleted the proxy's certificate files from the PC; the phone-side proxy setting and certificate were on my cleanup list.

The path that worked: my own account, one fetch

That left the script. It signs in to the same Tile account that the phone app uses, asks for the account's node list, and reads two things for each Tile: the auth key, and a small configuration value that is needed later to turn on button reporting. I changed it first so the password comes from a prompt or an environment variable instead of being typed on the command line, the output goes straight to the ignored file, and a failure prints only a status and a message, never a password or cookie.

On the run that mattered, the login was accepted and the script exited cleanly, with both Tiles carrying their configuration value. The password login was enough for my account; no email-code step came up. The keys sit in a folder outside the repository, and the Git history was scanned for the password and the account email afterward with no matches. One slip is worth owning: an early diagnostic output listed the shape of the account's response and leaked identifiers inside composite keys. It was never committed. I masked those values in the tool, added a test, and cleaned the file.

A small checker then reported, for each Tile, only the model, whether the ID parses, the key length and whether the Bluetooth address measured by scanning matches the one derived from the Tile's ID. Both Tiles reported a 16-byte key, a 4-byte base64 configuration value, and an address match. The app derives the address from the Tile's ID by a fixed rule when none is typed in, and the derived addresses matched the ones measured by scanning.

Closing the protocol gaps

On the same day, Claude worked through the gaps the project doc listed, and the desktop suite grew from 27 to 125 tests in two passes:

The app also got a status line and one Ring button per Tile, and a manifest audit fixed several foreground-service problems for Android 14 to 16 before anything was built.

Building the Android app

The machine had no Android toolchain, so Claude installed a JDK 17, Gradle 8.13 and the Android SDK, verifying each download against its published checksum. The first build succeeded with no code fixes, and the debug APK came out at about 26 MB. A scan of the APK for my keys, as text, raw bytes and hex, and for the Tile IDs and configuration values, found nothing. The keys are not in the app. They are copied into the app's private storage separately. The phone was not plugged in yet, so nothing was installed that night.

The first three runs on the phone

This was the first time any of it touched real hardware, and it went wrong in ways I had not predicted.

Run one. I installed the app, copied my Tile list into it, and granted permissions. The five-minute log was useless: the background service never started. The app had gone to the background within about 90 seconds, and the most likely explanation is that Start was never tapped. The log held only noise from other apps.

Run two. A layout bug turned up first. With Android 15 and later, apps draw edge to edge, and my screen was built in code with no padding for the system bars, so the Start button sat under the status bar. The fix wrapped the content in a scrolling view padded by the system insets. On the second run the service did start, both Tiles were registered, and then the controller crashed with this error: the scan callback class is not a Java interface. Android's ScanCallback is another abstract class, the same trap as the Bluetooth callback in the design, except this time my own code stepped in it. Nothing scanned, so pressing the Tiles did nothing. I also removed the Life360 app during this run, to take away anything competing for the trackers.

Run three. The fix was a small Kotlin class that extends ScanCallback and forwards results to an interface Python can implement, plus a test that fails the build if Python ever tries to wrap an abstract class again. The same change made connection progress and start failures visible in the logs and on the status line. Then it worked. The service started, the scan matched both Tiles, and each Tile connected, authenticated, opened a channel and reached READY, with the handshake from connection to READY taking about half a second. Rings went through: each Tile answered a SONG command and the status line showed that the Wallet ring was ok.

Three real problems also showed up in that log. First, 15 of 15 keepalive replies were rejected by the Tile with an error, and then the Tile closed the channel about 25 seconds after READY, over and over, so connections churned. Second, one ring failed because it arrived while the channel was closed and the code gave up at once. Third, the Tile app was still installed and running on the phone, which can compete for the same trackers. I also noticed that one logging line printed a full device address, which broke my own no-full-addresses rule, and masked it afterward.

The path that made ringing work

Looking back, ringing came from a chain of fixes, each one found by running on the real phone or reading a real log:

  1. Getting the Tile's auth key from my own account, because a Tile will not talk to a phone that cannot prove it holds the key.
  2. Using the right protocol: the FEED service, the HMAC-based handshake, and SONG as the ring command, all of which came from the October 13 check against the research and the reference code.
  3. Fixing the scan callback on October 21, which was the difference between nothing happening and a connection.
  4. Reaching READY quickly, so a ring has a live channel to travel on. The keepalive problem was still open when ringing first worked, so rings could only succeed inside the short window before each close.

Today, ringing works at close range.

Fixing the keepalive and making the app usable

The keepalive error was a missing byte. Claude read node-tile's handler again: the reply to a keepalive check is command 0x08 with a one-byte payload of 0x03, and mine had no payload. The Tile answered with an error whose bytes name the command, and then closed the channel. Confidence in that reading is medium. The reply format matches a working reference, but the exact meaning of the error bytes and of the roughly 20-second close is an interpretation. The payload lives in one constant so the next candidate, an empty payload, is a one-line change.

Other changes that day:

That evening the app also became always-on. Tracking is a switch on Home, on by default. It starts at boot, after an app update and when the app opens, restarts itself if Android kills it, and has a 15-minute backup alarm. I read Android's own documentation to confirm that this type of foreground service is allowed to start from boot on Android 15 and 16. A first-run Setup screen asks for the permissions, and a battery prompt sends me to the app's settings page to set it to Unrestricted. Settings has a Debug section with a confirmed test alert and a Simulate Tile button press that runs the same decision path as a real press with no sound.

I also added an optional feature, off by default: remember where my Tiles were last seen. While a Tile is connected or was heard recently, the phone's position is saved, no more than once a minute and only if I have moved about 25 meters or ten minutes have passed. The positions stay on the phone, are excluded from backup, and are never written to the log. The Tile card then says something like "Last seen 14 minutes ago, within 30 m" with an Open in Maps button, and each Tile gets a History list.

Settings, themes and a lot of polish

Today's work was about making the app pleasant to live with. A Settings screen now holds the alert sound (the default alarm, a phone sound or an audio file I import), alert length, vibration pattern, an optional volume raise that restores my volume afterward, and Low, Medium or High ring volume. It also has Light, Dark and Pure black themes, quiet hours, and a way to mute one Tile's button. Ring length is deliberately not offered, because the duration byte is still unverified. History now merges GPS jitter into stays with time ranges, has an offline sketch drawn on a plain canvas with no map library and no network, and an opt-in nearby-address lookup that is off by default and says up front that it sends the spot to Google once.

The same day also shows how this work goes wrong. The toggle switches took four tries. Dark themes made them nearly invisible, so one round drew custom ones, which came out oversized. The next went back to the platform switch with solid colors, and that made every thumb square, because the tint mode that round used paints a solid rectangle over the whole shape. The final fix removed the tint modes and kept only the colors. Whether that looks right on the phone is still to be confirmed, and the report warns the tracks may look paler than the palette in the dark themes. A "Ring both" button was added and removed within hours. A Recent activity list was added below the Tile cards: button presses, alerts, rings and their results, connects and drops, newest first, with repeated reconnects collapsed into one line.

Where button press and range stand

Button press

This is the part that is working in pieces. In the October 21 run, the Tiles sent button-press notifications beyond the ones explained by reconnects, and the app logged four debounced phone alerts, two for each Tile, each logged once with no doubled alerts per click. I could not match each one to a physical press, because the press times were not written down. The debouncer keys on the Tile and fires on the first press, then waits 0.75 seconds, so a burst gives one alert and a later press gives another. So the chain from button to log to alert looks right on paper and in tests, but I will not call button press done until I can press, hear it, and see exactly one alert every time. That test is next.

Range

I have not done a deliberate range test. The only range-like data is the signal strength in the October 21 logs, which sat between about -37 and -66 dBm. How far the connection holds, how fast it comes back after a drop, and whether a Tile in a bag in another room still presses through, are all unknown. The reconnect work from October 22 is aimed at the blind windows after a drop, and it has not been run on hardware yet.

How I built it

I direct the project and I test on the real hardware: the Pixel and the two Tiles. Claude Code writes the code, the tests and the written reports. Each round starts with a brief from me and ends with a report that states what changed, what the numbers were before and after, and, importantly, what could not be verified without a device. Those reports are where most of this story comes from. The repository has 80 commits as of today, all in small steps. The desktop suite has 180 Python tests and the Android side has 149 Kotlin unit tests, plus a lint pass and an APK scan for key material in each round.

It also means the honest limit of this story is that a lot of what was built on October 22 and 23 has been compiled and unit tested but has not yet been confirmed on the phone. The reports say so every time, and so do I.

On secrets, the rules were fixed from the start. The keys live in a file that Git ignores, outside the repo, and on the phone they sit in the app's private storage. They are not in the APK, the logger redacts them, and the app does not even request network access.

What's next

Tile Local is ongoing. The work in progress right now is button press and range, along with confirming the recent keepalive, reconnect, alert and always-on changes on real hardware. I'll keep this page updated as that work continues.

Updated October 2026.