I wanted my Discord bot to ring my phone. "Find my phone" is a solved problem - Google has a whole crowdsourced network for it - so I figured I'd wire up an existing tool and be done in an afternoon. Instead I spent the afternoon decompiling the Find Hub app, and came out the other side with a working reverse-engineering of an unpublished Google gRPC API. Here's what's actually going on under "ring my phone," in case you ever need to do this yourself.
The false start
The obvious starting point is GoogleFindMyTools, which reimplements enough of Google's Find My Device network to locate and ring trackers. It works great - for trackers. Point it at an account with a phone on it and the phone shows up in the device list as a hollow shell: a name, and nothing else. No identifier, no way to address an action to it.
That tool talks to what Google internally calls the Spot / Nova API (android.googleapis.com/nova/), a REST-ish endpoint where every device is keyed by a "canonic[al] id." My phone came back with an empty phoneInformation { } and no canonical id at all. It wasn't a settings problem or a propagation delay, as I first assumed and wasted time on. Phones simply aren't Spot devices.
Two backends, not one
Ringing a phone from the Find Hub website works fine, so the capability plainly exists - it just lives somewhere else. Pulling apart the Find Hub Android app (com.google.android.apps.adm) makes the split obvious. There are two entirely separate backends:
- Trackers live on the Spot/Nova REST API and are addressed by canonical id. This is what GoogleFindMyTools speaks.
- Phones live on a gRPC service called
google.internal.fmd.FmdApiServiceatfindmydevice-pa.googleapis.com, and are addressed by a 64-bit android id.
They don't overlap. Asking the tracker backend about a phone gets you the hollow shell; the phone's real identity only exists on the FMD service. So the whole task reduces to: talk to a gRPC service Google doesn't publish, in a protobuf schema nobody documents.
Recovering the schema
The app is R8-optimized, so the Java is an alphabet soup of one-letter class names and there's no following the control flow by eye. But protobuf survives obfuscation surprisingly well: the generated message classes each carry a compact descriptor string (the argument to newMessageInfo) that encodes every field's number, wire type, and whether it's a oneof. Decode those and you get the message layout back, field by field, even when the field names have been stripped.
Doing that across the request and response messages, plus reading the one handler class whose logging strings gave it away (MakeSoundActionHandler), reconstructs the pieces that matter:
service FmdApiService {
rpc ListDevices(ListDevicesRequest) returns (ListDevicesResponse);
rpc ExecuteAction(ExecuteActionRequest) returns (ExecuteActionResponse);
}
// A phone's identity is a 64-bit android id.
message AndroidId { int64 android_id = 1; }
message DeviceIdentifier { AndroidId android = 1; int32 device_type = 2; }
// The ring action. An empty message - confirmed in MakeSoundActionHandler,
// which sets the action oneof to case 1 with a default instance.
message MakeSound {}
message StopSound {} // oneof case 5
message Action { MakeSound make_sound = 1; StopSound stop_sound = 5; }
message ExecuteActionRequest {
DeviceIdentifier identifier = 1;
Action action = 2;
Metadata metadata = 3;
}
The action field is a oneof of roughly eighteen cases - locate, lock, wipe, factory-reset, and so on. This is exactly the place you want to be certain rather than clever: guessing the wrong case number could send a wipe instead of a ring. So I didn't guess. The handler code sets the oneof explicitly to case 1 (an empty MakeSound) for ring and case 5 for stop, and I modelled only those two, so the client physically cannot express the destructive ones.
Auth is the easy part
Happily, the FMD service accepts the same android_device_manager OAuth scope that the Nova path already mints - so no new credential dance. (The app also references an auth/spot scope, but that one comes back RESTRICTED_CLIENT when you request it as this client; it belongs to the tracker service. Worth knowing so you don't chase it.) The token goes on the gRPC call as an ordinary authorization: Bearer header. ListDevices then returns each phone with its android id populated, and you're most of the way there.
Two things that will bite you
The request assembled cleanly and the server rejected it with a bare INTERNAL error. Two non-obvious details separate "accepted" from "500":
- Echo the identifier verbatim. Don't rebuild a minimal
DeviceIdentifierwith just the android id - take the whole identifier message thatListDeviceshanded you and pass it straight intoExecuteAction. It carries a device-type field and other subfields the server expects; a stripped-down scope 500s. - Use a persistent reply channel, not a throwaway one. The request embeds an FCM registration token - the channel the server pushes the result back on. Here's the subtle part: the phone's ring lasts only as long as that channel stays alive. If you register a fresh FCM listener, fire the ring, and then tear the listener down a second later (the natural thing to do), the ring stops the instant it starts. The app uses its long-lived registration; so should you. Send the account's persistent token as a passive string and open no live connection at all, and the ring rings until dismissed.
That second one cost me a genuinely baffling few minutes of "it works, but it immediately stops." It looks like a bug in your action request; it's actually a property of how the ring is scoped to its reply channel.
The shape of the whole thing
Once those are sorted, ringing a phone is three steps:
- Mint an
android_device_managerbearer token for the account. - Call
ListDevices, find the entry with a non-zero android id, keep its identifier message. - Call
ExecuteActionwith that identifier, anActionwhosemake_soundcase is set, and metadata carrying the account's persistent FCM token.
The phone chimes. stop_sound silences it. The same client, keyed on a canonical id and pointed at the Nova API instead, still handles trackers - the two backends coexist behind one "ring the thing named X" command.