Squirreleye LLC Squirreleye Service Notes Naked Labs Mirror · Revival Series Index Tools About
11
Section 11 · follows 10

Cloud token, hardcoded cloud: the upload URL was never mine to set

Written
2026-05-16
Log
NakedMirrorDebug/logs/current.txt

The previous post ended on a clean win: thirteen networks parsed, the WiFi sheet worked, I picked <my-home-wifi>, the mirror connected. Step 13 went green for the first time in the project’s history. The plan was simple: run the dry-run, watch the mirror walk through ALIGN_SCALE → WARMING_UP → SCANNING → COMPLETE, see a POST arrive at my local listener, and finally answer the Scenario A vs B question that had been open for a month.

The dry-run ran twice. It failed both times. The way it failed sent me back to the APK.

§ 11.1

What I saw

First attempt, mirror in a clean scanState = .invalid:

17:43:50.894 WRITE alignScale (0x35)17:43:50.969 NOTIFY scanState -> .error (75 ms after the write) no scanError accompanies it

Second attempt, mirror in scanState = .error from the first attempt:

17:44:28.124 WRITE alignScale17:44:28.229 NOTIFY scanState -> .invalid17:44:28.245 NOTIFY scanError -> .noError (byte 1)17:44:28.255 NOTIFY scanState -> .error17:44:29.999 NOTIFY scanError -> .processorBusy (because I kept writing initDeviceForScan and cloudToken into a mirror that had already rejected the request)

Both attempts: alignScale produces scanState -> .error within 100 ms. No scanError byte accompanies the first transition. The mirror doesn’t say why it’s refusing.

This wasn’t a thing the existing protocol map predicted. Per STATUS.md’s table, the official sequence was alignScale -> initDeviceForScan -> cloudToken write. My diagnostic did exactly that. The mirror said no.

So the APK got another reading.

§ 11.2

Reading the presenter end to end

ui/activities/scan_initiation/ScanInitiationPresenter.java is the orchestrator on the Android side. 1068 lines. Last time I’d audited it (the pivot in post 08), I’d read enough to extract the action-byte sequence and the state enums. I hadn’t read the layer immediately above that: the activity-level call flow that decides when alignScale gets issued.

That decision is initScaleAlignFragment in the Activity:

Fig. 11-1
java
public void initScaleAlignFragment() { if (this.mScanInterrupted) return; presenter.stopCheckingScaleConnection(); presenter.checkMirrorState(true, true); // <- this presenter.alignScale(); startFragmentFromRight(ScaleAlignFragment.newInstance());}

checkMirrorState reads mirrorExternalState (82B0745A-…) and decodes the byte as a DeviceState enum. Per ScanInitiationPresenter.lambda$onSuccess$0$ScanInitiationPresenter$10:

Fig. 11-2
java
if (deviceState == DeviceState.IDLE || deviceState == DeviceState.INVALID) { // proceed, okay to align}else if (deviceState == DeviceState.ERROR) { presenter.getMirrorError(); // read mirrorError byte, show specific error return; // do NOT call alignScale}else if (deviceState == DeviceState.BUSY) { showErrorFragment(ScanError.PROCESSOR_BUSY); return; // do NOT call alignScale}else if (deviceState == DeviceState.NOT_CONFIGURED) { showErrorFragment(ScanError.NO_SCANNER_WIFI); return; // do NOT call alignScale}…

The official client doesn’t issue alignScale blindly. It reads the device’s state first, on a different characteristic from scanState, and bails if the mirror isn’t in IDLE or INVALID. The byte names in DeviceState make this look obvious in retrospect: BUSY, ERROR, NOT_CONFIGURED, CRITICAL_BATTERY, these are conditions the device announces about itself, independent of any in-progress scan.

My diagnostic has never read mirrorExternalState. Step 4 finds it during characteristic enumeration, labels it mirrorExternalState [read,notify], and then nothing touches it again. stepDryRunScan reads scanState for its pre-flight, sees .invalid, treats that as clean, and writes alignScale. Meanwhile mirrorExternalState could be holding ERROR (9) or BUSY (2) or NOT_CONFIGURED (7), and the mirror, when it receives alignScale from a non-IDLE state, has nothing to do except mark scanState = .error and ignore the request. Which is exactly what I saw, 75 ms after every write.

Fig. 11-3
sequenceDiagram
    participant Phone
    participant Mirror
    Phone->>Mirror: WRITE alignScale (0x35)
    Note over Mirror: mirrorExternalState is BUSY / ERROR / NOT_CONFIGURED
    Note over Mirror: (never read by my diagnostic)
    Mirror-->>Phone: NOTIFY scanState = .error (75 ms later, no scanError)

That’s finding one. The mirror is rejecting alignScale at the device-state layer, before the scan state machine even gets involved. The Android app would have known not to issue the write at all.

§ 11.3

And then the bigger one

While I was in the presenter, I went back to verify the third write in the sequence: the cloudToken write that’s supposed to carry my local listener’s URL so the mirror can POST a finished scan to me. Per my existing notes (STATUS.md -> “APK ground truth” table): “The official app fetches a per-user token from the cloud here; I substitute my local LocalScanServer URL.”

The sequence in the presenter is:

Fig. 11-4
java
public void initScan() { mNakedBleSDK.initDevice(/* onSuccess */ () -> { getUserToken(); // <- cloud call });} private void getUserToken() { mUserRepository.getUserToken(this.mDeviceHash) // <- POST to api.nakedfit.net .subscribe(userTokenDTO -> { sendTokenToDevice(userTokenDTO.getUserToken()); // <- write to BLE });} private void sendTokenToDevice(String str) { mNakedBleSDK.sendScanTokenToDevice(str, …);}

…and sendScanTokenToDevice resolves to NakedV1SetScanTokenOperation:

Fig. 11-5
java
public class NakedV1SetScanTokenOperation extends SetScanTokenOperation { public static final UUID CHAR_SCAN_TOKEN_UUID = UUID.fromString("884811AA-742D-424D-9513-A570379DC255"); public void execute(String str, BLECallback<Object, BLESdkError> cb) { mBleManager.doWriteOperation(CHAR_SCAN_TOKEN_UUID, str.getBytes(), …); }}

The Java type of the value being written is String. The constant name is CHAR_SCAN_TOKEN_UUID. The method is sendScanTokenToDevice. The class is SetScanTokenOperation. The DTO whose contents get written is UserTokenDTO, with one field, userToken. Every name in this chain says token. None of them says URL.

And where does that token come from? mUserRepository.getUserToken(deviceHash), which resolves to:

Fig. 11-6
java
public Single<UserTokenDTO> getUserToken(String str) { return this.mIUserServer.getUserToken(str); // Retrofit interface}

…against a Retrofit instance built with:

Fig. 11-7
java
.baseUrl("https://api.nakedfit.net/")

(infraestructure/networking/RetrofitManager.java:54, hardcoded.)

So the Android app does this: ask the cloud for a per-scan auth token, write the token to cloudToken over BLE. The mirror then uses that token, somehow, to authenticate something. What? Most likely the upload POST it makes to api.nakedfit.net at scan completion. The mirror’s upload destination is built into firmware. The token doesn’t redirect it; the token just signs it.

I’d been writing http://<my-ip>:7777/upload/d53469 to this char. The mirror has been taking 40 bytes of ASCII URL and treating them as a token string, meaningless from its perspective, but technically a write that succeeds. Then, if a scan ever completed, the mirror would have POSTed to the hardcoded api.nakedfit.net (dead) using my URL as a bearer token (nonsense). The POST would have failed for both reasons. I’d never have seen it at the local listener, not because of a protocol bug, but because that endpoint was never able to be redirected via BLE.

This recontextualizes a lot of past sessions. Every dry-run where I wrote a cloudToken value at all, the production app’s CountdownView.kickOffScan included, has been writing a token to that char, not a URL. The fact that the writes “succeeded at the BLE layer” was never evidence of anything except that the characteristic accepted bytes.

That’s finding two.

§ 11.4

What this means for the project

The project plan’s Scenario A vs B framing assumed the upload URL was BLE-configurable. It is not. So a third scenario joins:

Scenario C. The mirror posts to api.nakedfit.net (hardcoded), the cloud is dead, my local listener never receives the body, and no amount of writing different bytes to cloudToken changes this. To capture a scan body locally I have to work with the mirror’s outbound network request, not its BLE state. Three possible approaches:

  • LAN DNS override. Point api.nakedfit.net at my own Mac coordinator at the router level. Hinges on (a) the mirror using the network’s DNS, and (b) the mirror not strictly validating TLS certs (or me being able to present one it accepts).
  • On-path TLS proxy. Same idea, but at the HTTPS layer with a proxy that can decrypt and re-encrypt. Hinges on the mirror trusting a CA I can install, or not pinning certs at all.
  • Firmware-side redirect. Work on the mirror’s own compute board (Celeron + Android per project lore) to point its upload target at my endpoint directly. The most involved option; the fallback if the first two are blocked by cert validation.

The viability of the first two options hinges on three things I don’t know yet:

  • Is the api.nakedfit.net cert still in the mirror’s OS trust store? If it expired without a replacement, the mirror might already refuse to POST. (That would actually explain “I’ve never seen a successful upload” even if everything else worked.)
  • Does the mirror use the WiFi network’s DNS, or does it have hardcoded resolvers in firmware?
  • Does the mirror’s HTTP client do cert pinning, or just trust the system CAs?

A controlled-AP packet capture (a small device running hostapd + dnsmasq + tcpdump) would answer all three in one session. That’s the new gating experiment for any downstream work.

§ 11.5

What’s not changing in this commit

This commit doesn’t fix any of it. It writes down what’s now known, in Ref/SCAN_WORKFLOW.md, with file and line citations to the decompile. It updates my status and plan docs to flag Scenario C and point at the new authoritative doc. It doesn’t bump the version of the debug app, because nothing about the binary changes, just my mental model of the project.

The next code cycle, when it ships, will be informed by this. Step 15 needs to read mirrorExternalState before issuing alignScale, branch on the DeviceState byte the same way the Android app does, and read mirrorError if DeviceState is ERROR. It also needs to stop pretending the cloudToken write does what I thought it did, that whole sub-step is currently writing nonsense.

§ 11.6

What I learned

  • “Read the APK before iterating” applies recursively. Two weeks ago I’d audited the protocol layer and built my diagnostic against it. I hadn’t audited the layer above it: the activity-level orchestration that decides when each protocol primitive runs. The orchestration layer carries the preconditions. Without it I was calling the right bytes in the right order with no idea whether the device was in a state to accept them.
  • Variable names are evidence. CHAR_SCAN_TOKEN_UUID, sendScanTokenToDevice, UserTokenDTO.userToken, every name in that chain said the value was a token. My previous protocol notes said the value was an upload URL. When the names and the notes disagree, the names are usually right; the notes were written by someone (me) who reasoned about what the field would have to be for a particular architecture to work. The Android app had every reason to call this what it actually was.
  • A hardcoded cloud endpoint is a project-shaped fact. The Naked Labs product needed the cloud to be at one specific address, it was the business. Reviving the product after the company died means accepting that one specific address is in the firmware and isn’t being moved around at runtime. The intercept strategy is the rebuild, not the override.
  • Streaming logs paid off again. The two dry-run failures arrived back as readable evidence within seconds of testing on the mirror, no AirDrop, no version uncertainty. The cost of not knowing about Scenario C would have been weeks of architectural work going the wrong direction; the cost of finding out was an hour of re-reading the APK.
§ 11.7

What’s next

Two parallel tracks open up:

  • Next debug-app cycle: correct stepDryRunScan to mirror the Android app’s precondition pattern. Read mirrorExternalState, branch on it, surface mirrorError if relevant. Stop misrepresenting the cloudToken char in the log; rename it scanToken and label the write contents as opaque bytes, not a URL. Treat scanError = .noError (1) and .invalid (0) as non-error sentinels rather than failures.
  • New experimental track: controlled AP + DNS test. Stand up a small AP, point api.nakedfit.net at the Mac coordinator’s address via dnsmasq, watch what the mirror does. Three possible findings: it connects and POSTs (best case, DNS-only works), it refuses (TLS / cert), or it can’t reach the AP at all (different problem). Each tells me which of the three approaches I have available.

The compute architecture for the stitching pipeline, once I actually have a scan body in hand, doesn’t get touched by any of this. I just have to actually have one first, which is now a network-redirection problem, not a BLE-token problem.

§ 11.8
§ 11.9
Agent prompts Procedure 11-A as prompts for a coding agent · 5 prompts

Paste into Claude Code or a similar agent, one at a time, from the root of your iOS project. Each prompt maps to the step with the same number above.

1
Audit BLE write assumptions
Audit NakedMirrorDebug's scan-trigger step (stepDryRunScan in NakedMirrorDebug/DebugChecklistView.swift) for any log line that reports success purely because a CoreBluetooth write callback returned no error. For each one, check whether a subsequent notify or read (scanState, scanError) actually confirms the mirror acted on the write at the application layer, and add a comment noting that a write-success callback only confirms the GATT transport accepted the bytes, not that the firmware treated the content as valid.
2
Extract orchestration-layer preconditions
In the decompiled Android client used as this project's protocol reference, read the activity/presenter classes that call the per-operation BLE classes before issuing a protocol write — ScanInitiationPresenter.java and its initScaleAlignFragment method in particular. Extract every precondition check it runs (e.g. checkMirrorState) before each write, and record the decision tree (which DeviceState values allow the write, which short-circuit it) in Ref/SCAN_WORKFLOW.md so the Swift diagnostic can mirror the same gating logic instead of issuing writes in sequence with no precondition check.
3
Gate alignScale on mirrorExternalState
In NakedMirrorDebug/DebugChecklistView.swift's stepDryRunScan, add a pre-flight read of mirrorExternalState (MirrorUUIDs.mirrorExternalState, 82B0745A-85F0-400D-A9A5-15FFCF24BF93) before writing alignScale to scannerAction. Decode the returned byte against the DeviceState enum in MirrorUUIDs.swift. Only proceed with the alignScale write when the decoded value is .idle or .invalid; for every other value, bail out, and if the value is .error, also read mirrorError (25E29F6F-1F10-43C4-91FE-07B4364C1FB3) and log its decoded value as the specific failure cause.
4
Reconcile names against notes
Re-audit the cloudToken characteristic (MirrorUUIDs.cloudToken, 884811AA-742D-424D-9513-A570379DC255): grep the decompiled APK reference for every symbol that touches this UUID (CHAR_SCAN_TOKEN_UUID, sendScanTokenToDevice, SetScanTokenOperation, UserTokenDTO.userToken) and compare those names against this project's existing notes in Ref/SCAN_WORKFLOW.md that describe the characteristic as an upload URL. Where the decompiled names say "token" and the notes say "URL", correct the notes and the in-app log / friendlyNames label to match the decompiled semantics.
5
Locate the hardcoded upload host
Search the decompiled Android client's networking layer (e.g. RetrofitManager.java) for any hardcoded baseUrl() call tied to the scan-upload flow. Record the literal hostname in Ref/SCAN_WORKFLOW.md as the mirror firmware's actual upload destination, and add a note that no BLE characteristic can redirect it — any interception strategy has to operate on the network path (DNS, TLS proxy, or firmware-side) rather than on the BLE protocol.