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.
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 itSecond 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.
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:
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:
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.
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.
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:
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:
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:
public Single<UserTokenDTO> getUserToken(String str) { return this.mIUserServer.getUserToken(str); // Retrofit interface}…against a Retrofit instance built with:
.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.
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.netat 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.netcert 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.
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.
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.
What’s next
Two parallel tracks open up:
- Next debug-app cycle: correct
stepDryRunScanto mirror the Android app’s precondition pattern. ReadmirrorExternalState, branch on it, surfacemirrorErrorif relevant. Stop misrepresenting the cloudToken char in the log; rename itscanTokenand label the write contents as opaque bytes, not a URL. TreatscanError = .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.netat 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.
Follow along
Procedure 11-AThe story above has the dead-end attempts left in on purpose. If you’re reverse-engineering the same scan trigger on your own mirror, here’s the clean version:
- 1Don’t trust that a BLE write “succeeding” means anything semantic. A characteristic accepting bytes only tells you the write passed the BLE layer. It says nothing about whether the firmware treats the content the way you expect.
- 2Read the orchestration layer, not just the operation layer. The per-operation Java classes (
NakedV1...Operation) give you bytes and UUIDs. The Activity/Presenter classes above them (ScanInitiationPresenter,initScaleAlignFragment) give you the preconditions: what has to be true before each write is allowed to matter. - 3Read
mirrorExternalState(82B0745A-…) before writingalignScale. Decode it asDeviceState. Only proceed onIDLEorINVALID. OnERROR, also readmirrorErrorfor the specific cause. - 4Trust variable names over your own prior notes. If a characteristic is named
CHAR_SCAN_TOKEN_UUIDand written viasendScanTokenToDevice, it’s a token, not a redirect URL, no matter what your earlier assumptions said. - 5Expect the real upload destination to be hardcoded in firmware, not configurable over BLE. If the service is dead, the fix is on the network path (DNS, TLS, or the firmware’s own target), not the BLE characteristic.
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.