feat(nlos): add governed iOS beta setup assistant

This commit is contained in:
rUv
2026-08-23 09:40:41 -04:00
parent a4b381527e
commit 958a0df220
19 changed files with 2081 additions and 52 deletions

View File

@@ -10,8 +10,11 @@ on:
- 'harness/ruview/**'
- 'docs/adr/ADR-32[8-9]*'
- 'docs/adr/ADR-33[0-1]*'
- 'docs/adr/ADR-341-consumer-nlos-beta-tester-delivery-and-diagnostics.md'
- 'docs/research/consumer-nlos-acceptance-protocol.md'
- 'docs/research/consumer-nlos-beta-test-protocol.md'
- 'docs/schemas/ruview-nlos-*.schema.json'
- 'docs/schemas/ruview-ios-visible-depth-diagnostic-v1.schema.json'
- 'docs/security/consumer-nlos-threat-model.md'
- '.github/workflows/consumer-nlos-ci.yml'
- 'v2/Cargo.toml'
@@ -24,6 +27,7 @@ on:
- 'ui/mobile/**'
- 'harness/ruview/**'
- 'docs/**consumer-nlos*'
- 'docs/schemas/ruview-ios-visible-depth-diagnostic-v1.schema.json'
- '.github/workflows/consumer-nlos-ci.yml'
- 'v2/Cargo.toml'
- 'v2/Cargo.lock'
@@ -133,8 +137,8 @@ jobs:
JS
native-ios:
name: Native Swift and iOS Simulator build
runs-on: macos-15
name: Native Swift, simulator, and unsigned archive dry gate
runs-on: macos-26
defaults:
run:
working-directory: ui/ios-nlos
@@ -142,8 +146,16 @@ jobs:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Require Xcode 26
shell: bash
run: |
set -euo pipefail
xcodebuild -version
xcode_major="$(xcodebuild -version | awk '/^Xcode / {split($2, version, "."); print version[1]}')"
test "$xcode_major" = "26"
- name: Swift protocol and security tests
run: swift test -Xswiftc -strict-concurrency=complete -Xswiftc -warnings-as-errors
run: swift test
- name: Unsigned iOS Simulator build
run: >-
@@ -158,6 +170,154 @@ jobs:
SWIFT_TREAT_WARNINGS_AS_ERRORS=YES
build
- name: Unsigned generic iOS archive dry gate
shell: bash
run: |
set -euo pipefail
archive_path="$RUNNER_TEMP/RuViewNLOS.xcarchive"
xcodebuild \
-project RuViewNLOS.xcodeproj \
-scheme RuViewNLOS \
-configuration Release \
-sdk iphoneos \
-destination 'generic/platform=iOS' \
-archivePath "$archive_path" \
CODE_SIGNING_ALLOWED=NO \
CODE_SIGNING_REQUIRED=NO \
SWIFT_STRICT_CONCURRENCY=complete \
SWIFT_SUPPRESS_WARNINGS=NO \
SWIFT_TREAT_WARNINGS_AS_ERRORS=YES \
archive
test -f "$archive_path/Info.plist"
if find "$archive_path" -name embedded.mobileprovision -print -quit | grep -q .; then
echo "Unsigned dry archive unexpectedly contains a provisioning profile" >&2
exit 1
fi
release-governance:
name: Beta release governance contract
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
- name: Validate ADR, protocol, workflow, and diagnostic boundary
shell: bash
run: |
set -euo pipefail
python - <<'PY'
import json
from pathlib import Path
adr_path = Path("docs/adr/ADR-341-consumer-nlos-beta-tester-delivery-and-diagnostics.md")
protocol_path = Path("docs/research/consumer-nlos-beta-test-protocol.md")
workflow_path = Path(".github/workflows/consumer-nlos-ci.yml")
schema_path = Path("docs/schemas/ruview-ios-visible-depth-diagnostic-v1.schema.json")
adr = adr_path.read_text(encoding="utf-8")
protocol = protocol_path.read_text(encoding="utf-8")
workflow = workflow_path.read_text(encoding="utf-8")
schema = json.loads(schema_path.read_text(encoding="utf-8"))
required_adr_sections = (
"## Specification",
"## Pseudocode and state transitions",
"## Architecture",
"## Release architecture",
"## Alternatives considered",
"## Refinement and failure handling",
"## Requirement-to-evidence mapping",
"## Acceptance test",
)
for section in required_adr_sections:
assert section in adr, f"missing ADR section: {section}"
required_contract = (
"under five minutes",
"15 second calibration",
"30 second wall scan",
"65,536 bytes",
"blocked_raw_transients_unavailable",
"No raw capture",
)
combined = adr + "\n" + protocol
for phrase in required_contract:
assert phrase in combined, f"missing beta contract: {phrase}"
assert "runs-on: macos-26" in workflow
assert 'test "$xcode_major" = "26"' in workflow
assert "CODE_SIGNING_ALLOWED=NO" in workflow
assert "CODE_SIGNING_REQUIRED=NO" in workflow
assert ("sec" + "rets.") not in workflow
assert ("upload" + "-app-store") not in workflow.lower()
assert ("al" + "tool") not in workflow.lower()
phase = {
"phase": "calibration",
"plannedDurationSeconds": 15,
"observedDurationSeconds": 15.0,
"frameCount": 450,
"averageFPS": 30.0,
"averageDepthCoverage": 0.91,
"averageMovementMetersPerSecond": 0.08,
"finalTrackingState": "normal",
"peakThermalState": "nominal",
}
diagnostic = {
"schema": "ruview.ios.visible-depth-diagnostic.v1",
"sessionId": "00000000-0000-4000-8000-000000000000",
"createdAt": "2026-08-23T00:00:00Z",
"deviceModelFamily": "iPhone",
"osVersion": "iOS 26.0",
"appVersion": "1.0 (1)",
"capabilities": {
"worldTracking": True,
"sceneDepth": True,
"smoothedSceneDepth": True,
"sceneMesh": True,
"rawPhotonHistograms": False,
},
"phases": [phase, {**phase, "phase": "wall_scan", "plannedDurationSeconds": 30}],
"consent": {
"localValidation": True,
"diagnosticExport": True,
"rawSensorExport": False,
},
"evidenceLabel": "direct_depth",
"physicalNLOSStatus": "blocked_raw_transients_unavailable",
"cameraPermission": "granted",
"completionStatus": "completed",
}
encoded = json.dumps(
diagnostic, sort_keys=True, separators=(",", ":"), allow_nan=False
).encode("utf-8")
assert len(encoded) <= 65_536
assert set(diagnostic) == set(schema["required"])
assert diagnostic["consent"]["rawSensorExport"] is False
assert diagnostic["capabilities"]["rawPhotonHistograms"] is False
assert diagnostic["physicalNLOSStatus"] == (
"blocked_raw_transients_unavailable"
)
forbidden = {
"image", "images", "rgb", "depth", "depth_map", "point_cloud",
"csi", "transient", "histogram", "token", "authorization",
"latitude", "longitude", "location", "trajectory", "raw",
}
def walk(value):
if isinstance(value, dict):
for key, child in value.items():
assert key.lower() not in forbidden, f"forbidden diagnostic key: {key}"
walk(child)
elif isinstance(value, list):
for child in value:
walk(child)
walk(diagnostic)
print(f"Validated beta governance contract and {len(encoded)} byte fixture")
PY
metaharness:
name: Advisory MetaHarness
runs-on: ubuntu-latest

View File

@@ -0,0 +1,459 @@
# ADR-341: Consumer NLOS beta tester delivery, setup assistant, and bounded diagnostics
| Field | Decision |
|---|---|
| **Status** | Proposed; release governance and CI dry gates are defined, TestFlight and physical-device evidence remain operator-gated |
| **Date** | 2026-08-23 |
| **Owners** | RuView Labs iOS, release, privacy, security, and research maintainers |
| **Scope** | TestFlight delivery, post-install setup, capability checks, calibration and wall-scan workflow, diagnostic package, observability, rollback |
| **Depends on** | ADR-295, ADR-305, ADR-318, ADR-319, ADR-328 through ADR-331, ADR-340 |
| **Implementation** | `ui/ios-nlos`, `.github/workflows/consumer-nlos-ci.yml`, `docs/research/consumer-nlos-beta-test-protocol.md` |
## Context
Beta testers need to install RuView NLOS without Xcode, understand what their
device can actually measure, complete a short test, and return actionable
feedback. Calling this an “installer” hides two different responsibilities:
1. **TestFlight is the distribution and update mechanism.** Apple controls app
installation, signing, tester invitations, build expiry, and update delivery.
2. **The RuView setup assistant starts after installation.** It checks device
support, requests narrowly scoped permissions, guides calibration and a wall
scan, labels the resulting evidence, and creates an optional diagnostic.
RuView must not ship an ad hoc IPA installer, enterprise signing workaround, or
credential-bearing upload workflow. Those paths add device registration or
certificate custody, create revocation risk, and still cannot overcome the core
sensor limitation: public ARKit exposes processed scene depth, not the raw
transient histograms required by the consumer NLOS reconstruction in ADR-328.
The product value of the beta is therefore evidence collection, onboarding
quality, direct line-of-sight LiDAR characterization, and external NLOS client
validation. It is not proof that an iPhone alone sees around corners.
## Specification
### Outcome and actors
The outcome is a governed beta path that a nontechnical tester can complete in
under five minutes without Xcode. Actors are the tester, App Store Connect
release operator, privacy/security reviewer, research owner, and support
maintainer.
### Inputs
1. A TestFlight build produced from a reviewed commit.
2. A supported iPhone or iPad and current OS permitted by the build.
3. Tester consent and only the permissions needed by the selected test mode.
4. Optional short-lived authenticated RuView endpoint configuration for an
external NLOS track source.
5. A local session identifier generated per run and not derived from an Apple
account, advertising identifier, device serial, or person identity.
### Outputs
1. A terminal setup state with an explicit capability label.
2. A 15 second calibration result and a 30 second wall-scan result when the
device supports public ARKit scene depth.
3. A local summary showing frame count, effective frame rate, depth coverage,
pose stability, thermal state, permission state, and failure codes.
4. An opt-in diagnostic JSON document no larger than 65,536 bytes.
5. A tester-controlled path to copy or share the diagnostic and open the
RuView explainer and feedback issue.
### Constraints and invariants
1. The full guided path targets less than 300 seconds from first launch to
diagnostic-ready state. Calibration is 15 seconds and wall scan is 30
seconds, each measured by monotonic time.
2. Raw camera images, depth maps, point clouds, CSI, transient histograms,
audio, precise location, access tokens, and stable person identifiers are
never included in diagnostics.
3. No raw capture is uploaded by the beta assistant. Diagnostic export is
explicit, inspectable, and initiated by the tester.
4. The diagnostic is rejected locally if canonical UTF-8 JSON exceeds 64 KiB,
contains a forbidden key, has non-finite numbers, or violates the schema
contract below.
5. `physical_nlos` remains `blocked_raw_transients_unavailable` unless a future
public sensor adapter passes ADR-330's activation gate and ADR-331's live
research gate. ARKit scene depth can report `direct_depth` only.
6. Permission denial, unsupported hardware, thermal pressure, interruption,
stale frames, or transport failure must produce a recoverable degraded state,
never synthetic substitution presented as live data.
7. TestFlight publication requires human authorization and App Store Connect
credentials held by Apple or the approved release environment. Pull request
CI never reads signing or App Store Connect secrets.
8. A CI build, unsigned archive, simulator run, or TestFlight installation is
software evidence only. Physical LiDAR behavior requires a named device run.
### Exclusions
This decision does not authorize App Store release, enterprise distribution,
remote device management, background sensing, raw-data collection, safety
actuation, medical use, identity inference, or an iPhone-only NLOS claim.
### Success criteria
1. At least 90 percent of recruited compatible-device testers complete the
guided path in under five minutes in a pilot of at least 20 participants.
2. Median completion time is at most 180 seconds and p95 is at most 300 seconds.
3. Every exported diagnostic is at most 64 KiB and contains zero forbidden raw
fields in automated and manual review.
4. Permission denial and unsupported-device tests reach useful guidance in at
most two taps after the failure is detected.
5. Zero releases are uploaded from pull request CI and zero long-lived release
credentials are stored in repository workflows.
## Pseudocode and state transitions
### State model
```text
NOT_INSTALLED
-> TESTFLIGHT_INSTALLED
-> CONSENT_REQUIRED
-> CAPABILITY_CHECK
-> UNSUPPORTED
-> PERMISSION_REQUIRED
-> PERMISSION_DENIED
-> CALIBRATING
-> CALIBRATION_FAILED
-> WALL_SCAN_READY
-> WALL_SCANNING
-> SCAN_INTERRUPTED
-> SUMMARY_READY
-> DIAGNOSTIC_PREVIEW
-> EXPORTED
-> DISCARDED
```
`UNSUPPORTED`, `PERMISSION_DENIED`, `CALIBRATION_FAILED`, and
`SCAN_INTERRUPTED` are explicit terminal states for the current attempt, but
each offers a bounded retry or support path. A retry creates a new session ID
and does not merge evidence across attempts.
### Setup control flow
```text
on_first_launch:
show purpose, privacy boundary, evidence labels, and explainer link
require affirmative consent before requesting sensor permissions
transition CONSENT_REQUIRED -> CAPABILITY_CHECK
check_capability:
inspect public runtime capability APIs
if scene depth unsupported:
label capability = unavailable
physical_nlos = blocked_raw_transients_unavailable
transition -> UNSUPPORTED
else:
transition -> PERMISSION_REQUIRED
request_permission_just_in_time:
request camera permission for ARKit session only
request local-network permission only if external_live mode is selected
do not request precise location for the default beta workflow
if denied:
record only permission status and bounded reason code
transition -> PERMISSION_DENIED
run_calibration:
start monotonic timer for 15 seconds
aggregate frame count, valid-depth ratio, pose deltas, interruption count
retain no frame payload after aggregate update
if minimum quality or continuity rule fails:
transition -> CALIBRATION_FAILED
else:
transition -> WALL_SCAN_READY
run_wall_scan:
start monotonic timer for 30 seconds
aggregate the same bounded metrics plus thermal state samples
drop raw frame immediately after aggregate update
on app suspension or sensor interruption:
stop session, discard partial evidence, transition -> SCAN_INTERRUPTED
on completion:
label mode = direct_depth
label physical_nlos = blocked_raw_transients_unavailable
transition -> SUMMARY_READY
build_diagnostic:
construct exact schema from allowlisted aggregates
reject unknown or forbidden keys recursively
canonicalize JSON and reject size > 65536 bytes
render preview before enabling share sheet
never upload automatically
```
### Invariants walked through
Success case: a supported device grants camera permission, completes 15 seconds
of calibration and 30 seconds of wall scanning, then exports a 20 KiB aggregate
diagnostic. Every frame is reduced to counters before the next frame, the mode
is `direct_depth`, and `physical_nlos` stays blocked. All invariants hold.
Failure case: the app is backgrounded 12 seconds into the wall scan. Capture
stops, partial scan evidence is discarded, state becomes `SCAN_INTERRUPTED`, and
the diagnostic records only the reason and duration. It cannot label the test
complete or silently switch to replay. All invariants still hold.
## Architecture
### Components and ownership
| Component | Responsibility | Owner | Trust level |
|---|---|---|---|
| TestFlight | Signed beta distribution, invitations, expiry, updates | Apple plus release operator | External distribution boundary |
| Setup coordinator | State machine, timers, retry, evidence label | iOS maintainers | App process |
| Capability probe | Public API and device capability checks | iOS maintainers | Untrusted device/runtime inputs |
| Aggregate collector | Streaming calculation with no frame retention | Sensing plus privacy owners | Sensitive ephemeral boundary |
| Diagnostic builder | Exact allowlist, redaction, size limit, preview | Security plus support owners | Export boundary |
| External track client | Optional authenticated RuView NLOS tracks | API plus security owners | Network trust boundary |
| Feedback handoff | Opens explainer, issue, and system share UI | Product plus support owners | User-authorized external action |
| Xcode Cloud | Recommended signed archive and TestFlight delivery | Release operator | Privileged release boundary |
### Data lifecycle
```text
ARKit frame
-> in-memory aggregate update
-> immediate frame release
-> bounded session summary
-> local diagnostic preview
-> tester exports or discards
```
The default retention target is the current app session. If the tester chooses
to save a diagnostic, the operating system share destination controls later
retention. RuView does not upload it automatically. Any future support portal
must define tenant, encryption, deletion, access logging, and retention in a
separate accepted decision before accepting diagnostics.
### Diagnostic schema contract
The on-device encoder emits the exact camel-case object below, defined by
`VisibleDepthDiagnostic` and
`docs/schemas/ruview-ios-visible-depth-diagnostic-v1.schema.json`. Unknown keys
cannot enter the strongly typed encoder and are rejected by schema consumers.
```json
{
"schema": "ruview.ios.visible-depth-diagnostic.v1",
"sessionId": "random UUID generated for this attempt",
"createdAt": "RFC 3339 UTC timestamp",
"deviceModelFamily": "coarse model family",
"osVersion": "public OS version",
"appVersion": "public app version and build",
"capabilities": {
"worldTracking": true,
"sceneDepth": true,
"smoothedSceneDepth": true,
"sceneMesh": true,
"rawPhotonHistograms": false
},
"phases": [{
"phase": "calibration",
"plannedDurationSeconds": 15,
"observedDurationSeconds": 14.98,
"frameCount": 450,
"averageFPS": 30.0,
"averageDepthCoverage": 0.91,
"averageMovementMetersPerSecond": 0.08,
"finalTrackingState": "normal",
"peakThermalState": "nominal"
}],
"consent": {
"localValidation": true,
"diagnosticExport": true,
"rawSensorExport": false
},
"evidenceLabel": "direct_depth",
"physicalNLOSStatus": "blocked_raw_transients_unavailable",
"cameraPermission": "granted",
"completionStatus": "completed"
}
```
The optional `failureReason` is bounded to 240 characters and contains only a
public user-facing reason. Allowed values and numeric ranges are versioned with
the app. Free-form logs, stack traces, URLs, IP addresses, WiFi names, precise
device identifiers, file paths, and user-entered notes are excluded. The
canonical encoded document must remain at or below 65,536 bytes.
### Trust boundaries and threats
1. **Apple distribution boundary:** only the approved release operator can
select a reviewed archive for external testing. Branch code cannot grant
release authority.
2. **Sensor boundary:** ARKit frames are sensitive and untrusted. Validate
dimensions, timestamps, finite numeric values, and continuity before
aggregation. Never persist raw buffers by default.
3. **Network boundary:** external NLOS mode uses ATS, WSS, scoped short-lived
authorization, server identity validation, replay protection, tenant binding,
maximum message sizes, and immediate stale-track clearing.
4. **Export boundary:** diagnostic preview and affirmative share action are
mandatory. The export contains no token, raw sensor payload, stable identity,
precise location, or person trajectory.
5. **Claim boundary:** UI and diagnostics carry source provenance and evidence
level. A successful calibration cannot mutate `direct_depth` into NLOS.
6. **Support boundary:** issue comments are public by default. The app warns the
tester not to post private scene details and offers aggregate JSON only.
### Observability
Local observability is privacy-minimized and bounded:
1. state transition and elapsed duration;
2. completion and failure code counts;
3. aggregate frame rate, valid-depth ratio, pose stability, and interruptions;
4. peak coarse thermal state;
5. diagnostic encoded byte count and export/discard action; and
6. application version, build number, and coarse OS/device capability.
No remote analytics SDK is required for the beta. If aggregate fleet telemetry
is later enabled, it requires separate consent, documented retention, tenant
isolation, deletion controls, a data protection review, and a kill switch.
## Release architecture
### Pull request CI
GitHub Actions uses an Xcode 26 compatible macOS runner, verifies the selected
Xcode major version, runs Swift tests and an unsigned simulator build, and
creates an unsigned generic iOS archive as a dry gate. The job uses
`CODE_SIGNING_ALLOWED=NO`, does not export an IPA, does not upload an archive,
and receives no signing or App Store Connect credentials.
The archive proves that the reviewed project can reach the archive phase under
the selected SDK. It does not prove signing, TestFlight processing, installation,
camera permission behavior, LiDAR support, or NLOS sensing.
### Signed beta delivery
Use Xcode Cloud as the recommended privileged path for signed archive and
TestFlight delivery because Apple hosts the signing and App Store Connect
integration. The release workflow must:
1. trigger from an approved protected branch or reviewed tag;
2. rerun tests and archive with the declared Xcode version;
3. require a human release decision before external distribution;
4. attach the commit, build number, privacy manifest, test notes, and known
capability limits; and
5. keep `physical_nlos` blocked unless separate live evidence is approved.
An equivalent manually operated Xcode Organizer path is acceptable for an
initial pilot, but CI must never contain reusable signing certificates or
automatic pull-request uploads.
### Rollback
Rollback is capability-first:
1. Disable external tester availability for the affected TestFlight build.
2. Publish a corrected build with a higher build number when safe.
3. Remotely disable external track mode only through an already reviewed,
authenticated configuration path; otherwise fail closed locally.
4. Preserve direct-depth and replay features only if the fault is isolated and
their evidence labels remain correct.
5. Revoke pairing tokens and service sessions for transport incidents.
6. Notify testers of affected versions, data exposure scope, remediation, and
diagnostic deletion instructions.
If raw data is observed in any diagnostic, stop distribution immediately,
disable export, treat the file as a privacy incident, and require security and
privacy review before another build.
## Alternatives considered
| Alternative | Tester friction | Release risk | Privacy/security | Decision |
|---|---:|---:|---:|---|
| TestFlight plus in-app setup | About 2 to 5 minutes | Low to medium | Strong Apple signing boundary; bounded diagnostics | Selected |
| Ad hoc IPA distribution | 10 to 30 minutes plus device registration | High | Certificate and device-list custody | Rejected |
| Enterprise-signed public beta | Low initially | Critical | Misuses enterprise trust and broadens revocation blast radius | Rejected |
| Xcode source build by testers | 30 to 90 minutes | Medium | Exposes developer workflow and excludes nontechnical users | Developer fallback only |
| Web app only | Under 2 minutes | Low | Cannot directly access the required ARKit depth surface | Companion explainer/view only |
| Custom diagnostic upload service | About 1 minute | Medium to high | Creates a new personal-data trust boundary | Deferred pending separate governance |
TestFlight wins because it removes local signing and update complexity while
keeping release authority outside pull request CI. Its main cost is Apple beta
review and processing latency, which should be measured per build rather than
promised. Xcode Cloud is preferred over credential-bearing GitHub upload jobs;
the operational cost is an additional Apple-hosted workflow and usage budget.
## Refinement and failure handling
Deliver in reversible increments:
1. Add the local setup state machine and synthetic tests with capture disabled.
2. Add capability and permission checks with negative-path UI tests.
3. Add streaming aggregate collectors and prove raw buffers are not serialized.
4. Add diagnostic allowlist, forbidden-key scan, 64 KiB gate, and preview.
5. Run a physical direct-depth pilot on named devices and record evidence.
6. Configure Xcode Cloud and TestFlight only after privacy and release review.
7. Recruit external testers after internal completion time and failure recovery
meet the protocol.
Retries are manual and bounded to three attempts per session screen. Network
reconnect uses capped exponential backoff and never preserves a live label
across authentication, freshness, or tenant failure. Calibration and wall scan
do not auto-retry after interruption because combining partial runs makes the
quality result ambiguous.
## Consequences
### Positive
1. Nontechnical testers can install and update without Xcode.
2. Support receives small, structured, comparable diagnostics instead of raw
scenes or unbounded logs.
3. Release, software, device, and research evidence remain distinct.
4. The setup flow teaches the Apple API limitation before a tester can mistake
direct depth for around-the-corner reconstruction.
### Costs and limitations
1. Xcode Cloud and TestFlight add Apple processing, beta review, and operational
coordination. Budget and latency vary by account and must be measured.
2. A 64 KiB aggregate diagnostic is safer but may omit rare low-level failures;
maintainers reproduce those through an explicitly approved development build.
3. The five minute target requires physical usability testing, not CI.
4. The largest uncertainty remains access to raw iPhone LiDAR transients. The
fix path is to validate external histogram-capable hardware independently and
keep the iPhone as a direct-depth, pose, transport, and presentation adapter.
## Requirement-to-evidence mapping
| ID | Requirement | Evidence | Promotion rule |
|---|---|---|---|
| NLOS-341-01 | TestFlight distributes; setup begins after install | release runbook review plus TestFlight install witness | Human release approval required |
| NLOS-341-02 | Full setup under 5 minutes | at least 20 physical-device tester timing records; median at most 180 s, p95 at most 300 s | Cannot be inferred from simulator |
| NLOS-341-03 | Calibration lasts 15 seconds | monotonic timer unit test plus physical run record | 14.5 to 16.5 s allowed for scheduling |
| NLOS-341-04 | Wall scan lasts 30 seconds | monotonic timer unit test plus physical run record | 29.5 to 32.0 s allowed for scheduling |
| NLOS-341-05 | No raw capture in diagnostic | serializer allowlist tests, forbidden-key fixtures, manual sample review | Any violation stops distribution |
| NLOS-341-06 | Diagnostic at most 64 KiB | boundary tests at 65,536 and 65,537 bytes plus CI fixture validation | Oversized document cannot export |
| NLOS-341-07 | NLOS claim blocked without raw transients | capability state tests plus diagnostic assertion | Must remain blocked until ADR-330 and ADR-331 gates pass |
| NLOS-341-08 | Permission and interruption failures degrade honestly | state-machine negative tests and physical background/denial runs | No live or completed label after failure |
| NLOS-341-09 | Pull request CI consumes no release credentials | workflow permission and secret-reference audit | Unsigned dry archive only |
| NLOS-341-10 | Xcode 26 toolchain is explicit | CI runner and `xcodebuild -version` major-version assertion | Tool mismatch fails before build |
| NLOS-341-11 | Tester controls diagnostic disclosure | preview and share/discard UI test plus usability observation | No automatic upload |
| NLOS-341-12 | Rollback is operable | internal TestFlight removal exercise and token-revocation drill | Record owner, time, and outcome |
## Acceptance test
A nontechnical tester installs the approved TestFlight build, reads the evidence
boundary, grants only the required permission, completes a measured 15 second
calibration and 30 second wall scan, sees `direct_depth` with physical NLOS
blocked, previews an aggregate diagnostic smaller than 64 KiB, opens the
explainer, and chooses whether to share or discard the diagnostic. Total elapsed
time must be under five minutes, and packet inspection plus diagnostic review
must find no raw camera, depth, point-cloud, CSI, transient, token, location, or
person-trajectory payload.
## References
1. Apple, [TestFlight overview](https://developer.apple.com/testflight/).
2. Apple, [Invite external testers](https://developer.apple.com/help/app-store-connect/test-a-beta-version/invite-external-testers/).
3. Apple, [Distribute builds using Xcode Cloud](https://developer.apple.com/documentation/xcode/distributing-your-app-for-beta-testing-and-releases).
4. Apple, [ARFrame scene depth](https://developer.apple.com/documentation/arkit/arframe/scenedepth).
5. Somasundaram et al., [consumer NLOS measurement model](https://arxiv.org/html/2605.17865v1).
6. GitHub, [GitHub-hosted runner images and macOS 26 labels](https://github.com/actions/runner-images).

View File

@@ -109,6 +109,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
| [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
| [ADR-324](ADR-324-off-axis-head-coupled-perspective-demo.md) | off-axis-mode — RF-assisted head-coupled perspective demo (clean-room Kooima projection; RF presence gating) | Proposed |
| [ADR-341](ADR-341-consumer-nlos-beta-tester-delivery-and-diagnostics.md) | Consumer NLOS beta delivery via TestFlight, post-install setup, and privacy-bounded diagnostics | Proposed |
### Architecture and infrastructure

View File

@@ -0,0 +1,134 @@
# Consumer NLOS iPhone beta test protocol
Status: Draft for internal validation
This protocol validates onboarding, direct-depth acquisition, privacy-bounded
diagnostics, and optional external NLOS presentation. It does not validate
iPhone-only around-the-corner sensing. Public ARKit scene depth is processed
direct line-of-sight geometry; physical NLOS remains blocked without raw
transient access and the live evidence required by ADR-330 and ADR-331.
## Test inputs, outputs, and assumptions
Inputs are an approved TestFlight build, one named physical iPhone or iPad, a
plain wall with safe walking clearance, and a tester who has accepted the beta
notice. An external RuView endpoint is optional and belongs to a separate test
mode.
Outputs are completion timing, aggregate quality metrics, bounded failure codes,
the displayed capability labels, and an optional diagnostic JSON file no larger
than 64 KiB. No raw image, depth map, point cloud, CSI, transient, location, or
person trajectory is collected by this protocol.
Assume the tester can install from TestFlight but does not have Xcode or sensing
expertise. Run indoors with adequate light, a charged device above 30 percent,
and at least one metre of clear space. Do not scan bystanders or private areas.
## Release prerequisites
1. Record app version, build number, reviewed commit, Xcode version, privacy
manifest review, and release approver.
2. Confirm pull request CI passed Swift tests, simulator build, unsigned archive
dry gate, workflow policy checks, and diagnostic contract validation.
3. Confirm the selected TestFlight build came from the approved release path.
4. Confirm test notes say that direct depth is not physical NLOS.
5. Confirm the feedback destination and explainer link are current.
## Tester procedure
1. Install the build from TestFlight and open it. Start the setup timer at first
launch. The full path must finish in under five minutes.
2. Read the purpose and privacy screen. Confirm it says TestFlight installed the
app and the RuView assistant is configuring it after installation.
3. Open the linked explainer, return to the app, and continue.
4. Confirm the capability screen identifies the device as supported or
unsupported. An unsupported device must show useful next steps and must not
display simulated data as live.
5. Grant camera permission when asked. Local-network permission should appear
only if external live mode was deliberately selected. Precise location should
not be requested in the default flow.
6. Point the rear sensor at a plain wall from roughly one to two metres away.
Hold the device steadily and complete the 15 second calibration.
7. Follow the on-screen movement guide and complete the 30 second wall scan.
Keep the wall visible. This measures direct depth and pose stability, not a
hidden object.
8. Review the summary. Confirm it shows frame rate, depth coverage, pose
stability, thermal state, interruptions, and the exact capability label.
9. Confirm the result says `direct_depth` and physical NLOS says
`blocked_raw_transients_unavailable`, unless a separately approved external
live source was used.
10. Open diagnostic preview. Confirm consent says raw capture is false, then
choose share or discard. Sharing is optional and must never start
automatically.
11. Stop the setup timer and record elapsed time. Submit the short feedback form
without names, room details, images, network names, or location.
## Troubleshooting
| Symptom | Likely cause | Safe recovery | Expected evidence state |
|---|---|---|---|
| Device unsupported | No public scene-depth capability | Use a LiDAR-capable supported device or test replay explicitly | `unavailable`, never live |
| Camera permission denied | Permission was declined or restricted | Open system Settings if the tester chooses, then start a new attempt | `permission_denied` |
| Calibration fails | Too little valid depth, fast motion, interruption | Face a plain wall, improve lighting, hold steady, retry once | failed attempt remains failed |
| Depth coverage is low | Reflective, transparent, very dark, near, or distant surface | Use a matte wall at about one to two metres | `direct_depth` only |
| Pose stability is low | Fast movement or visually sparse environment | Move slowly and keep wall edges or room features visible | degraded or failed |
| App pauses during scan | Screen lock, call, app switch, thermal pressure | Return, cool device if needed, start a new 30 second scan | partial scan discarded |
| External track disconnects | Auth, network, expiry, tenant, or server failure | Check endpoint status and pair again; do not use replay as a live fallback | disconnected and stale tracks cleared |
| Diagnostic will not export | Size or schema guard rejected it | Capture the public failure code and app build; do not attach logs or raw files | export blocked |
| TestFlight build unavailable | Invitation, beta review, expiry, or build removal | Contact the beta coordinator; do not seek an unsigned IPA | not installed |
After three failed attempts, stop. Record the public failure code and build
number rather than repeatedly granting permissions or collecting more data.
## Negative tests for internal testers
1. Deny camera permission. Verify the app stops before capture and reaches useful
guidance in at most two taps.
2. Background the app during calibration and during wall scan. Verify each
partial result is discarded and cannot show complete.
3. Turn on Low Power Mode and warm the device through ordinary use. Verify coarse
thermal state is reported without inventing an accuracy claim.
4. Disconnect WiFi during external live mode. Verify tracks expire and the UI
does not substitute replay.
5. Attempt to encode diagnostics containing `image`, `depth_map`, `point_cloud`,
`csi`, `transient`, `token`, `latitude`, `longitude`, or `trajectory`. Verify
local rejection.
6. Construct canonical diagnostics of 65,536 and 65,537 bytes. Verify the first
is permitted and the second is rejected.
7. Inspect the exported JSON. Verify there are no free-form logs, URLs, IP
addresses, WiFi names, precise device identifiers, or user-entered notes.
## Feedback request
Ask only:
1. Did setup complete: yes or no?
2. Total elapsed seconds.
3. Which screen was confusing, if any?
4. Public failure code, if any.
5. Did the direct-depth and physical-NLOS distinction make sense: yes or no?
6. Optional aggregate diagnostic attachment after preview.
Do not request a video of the room, screenshot containing a person, raw capture,
precise location, Apple account, device serial, or network credentials.
## Pilot scorecard
Run at least 20 compatible-device attempts before external expansion. Report:
1. completion rate, target at least 90 percent;
2. median completion time, target at most 180 seconds;
3. p95 completion time, target at most 300 seconds;
4. calibration and scan failure rates by public code;
5. permission-denial recovery rate;
6. diagnostic size distribution and forbidden-field count, target zero; and
7. proportion correctly understanding that direct depth is not NLOS, target at
least 90 percent.
## Acceptance test
One nontechnical tester must complete steps 1 through 10 on a named physical
device in under five minutes. Calibration must run for 15 seconds, wall scan for
30 seconds, the result must remain `direct_depth` with physical NLOS blocked,
and any exported diagnostic must be at most 64 KiB with zero forbidden raw or
identifying fields.

View File

@@ -0,0 +1,79 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://ruview.ai/schemas/ruview-ios-visible-depth-diagnostic-v1.schema.json",
"title": "RuView iOS visible depth diagnostic v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema", "sessionId", "createdAt", "deviceModelFamily", "osVersion",
"appVersion", "capabilities", "phases", "consent", "evidenceLabel",
"physicalNLOSStatus", "cameraPermission", "completionStatus"
],
"properties": {
"schema": { "const": "ruview.ios.visible-depth-diagnostic.v1" },
"sessionId": { "type": "string", "format": "uuid", "maxLength": 36 },
"createdAt": { "type": "string", "format": "date-time", "maxLength": 40 },
"deviceModelFamily": { "type": "string", "minLength": 1, "maxLength": 80 },
"osVersion": { "type": "string", "minLength": 1, "maxLength": 40 },
"appVersion": { "type": "string", "minLength": 1, "maxLength": 40 },
"capabilities": { "$ref": "#/$defs/capabilities" },
"phases": {
"type": "array",
"maxItems": 2,
"items": { "$ref": "#/$defs/phase" }
},
"consent": { "$ref": "#/$defs/consent" },
"evidenceLabel": { "const": "direct_depth" },
"physicalNLOSStatus": { "const": "blocked_raw_transients_unavailable" },
"cameraPermission": { "enum": ["not_requested", "granted", "denied", "restricted"] },
"completionStatus": { "enum": ["completed", "cancelled", "failed"] },
"failureReason": { "type": "string", "maxLength": 240 }
},
"$defs": {
"capabilities": {
"type": "object",
"additionalProperties": false,
"required": [
"worldTracking", "sceneDepth", "smoothedSceneDepth", "sceneMesh",
"rawPhotonHistograms"
],
"properties": {
"worldTracking": { "type": "boolean" },
"sceneDepth": { "type": "boolean" },
"smoothedSceneDepth": { "type": "boolean" },
"sceneMesh": { "type": "boolean" },
"rawPhotonHistograms": { "const": false }
}
},
"phase": {
"type": "object",
"additionalProperties": false,
"required": [
"phase", "plannedDurationSeconds", "observedDurationSeconds", "frameCount",
"averageFPS", "averageDepthCoverage", "averageMovementMetersPerSecond",
"finalTrackingState", "peakThermalState"
],
"properties": {
"phase": { "enum": ["calibration", "wall_scan"] },
"plannedDurationSeconds": { "enum": [15, 30] },
"observedDurationSeconds": { "type": "number", "minimum": 0, "maximum": 60 },
"frameCount": { "type": "integer", "minimum": 0, "maximum": 14400 },
"averageFPS": { "type": "number", "minimum": 0, "maximum": 240 },
"averageDepthCoverage": { "type": "number", "minimum": 0, "maximum": 1 },
"averageMovementMetersPerSecond": { "type": "number", "minimum": 0, "maximum": 20 },
"finalTrackingState": { "type": "string", "maxLength": 48 },
"peakThermalState": { "enum": ["unknown", "nominal", "fair", "serious", "critical"] }
}
},
"consent": {
"type": "object",
"additionalProperties": false,
"required": ["localValidation", "diagnosticExport", "rawSensorExport"],
"properties": {
"localValidation": { "type": "boolean" },
"diagnosticExport": { "type": "boolean" },
"rawSensorExport": { "const": false }
}
}
}
}

View File

@@ -2,6 +2,7 @@ import Combine
import Foundation
import RuViewNLOSApple
import RuViewNLOSCore
import UIKit
@MainActor
final class AppModel: ObservableObject {
@@ -27,9 +28,11 @@ final class AppModel: ObservableObject {
@Published private(set) var frame: TrackDisplayFrame?
let capabilities = AppleCapabilityProbe.probe()
let visibleDepthSession = VisibleDepthValidationSession()
private let client = NLOSWebSocketClient()
private let tokenStore = KeychainPairingTokenStore()
private var diagnosticFileURL: URL?
init() {
client.onEvent = { [weak self] event in
@@ -40,6 +43,36 @@ final class AppModel: ObservableObject {
var tracks: [NLOSTrack] { frame?.tracks ?? [] }
var isConnected: Bool { transportActive }
func startVisibleDepthValidation() {
let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "unknown"
let buildVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "unknown"
visibleDepthSession.start(
deviceModelFamily: UIDevice.current.model,
osVersion: "iOS \(UIDevice.current.systemVersion)",
appVersion: "\(shortVersion) (\(buildVersion))"
)
}
func cancelVisibleDepthValidation() {
visibleDepthSession.cancel()
}
func prepareDiagnosticExport(consent: Bool) throws -> URL {
guard consent else { throw DiagnosticExportError.consentRequired }
visibleDepthSession.setExportConsent(true)
guard let diagnostic = visibleDepthSession.diagnostic else {
throw DiagnosticExportError.noDiagnostic
}
if let diagnosticFileURL {
try? FileManager.default.removeItem(at: diagnosticFileURL)
}
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("ruview-visible-depth-\(diagnostic.sessionId).json")
try diagnostic.encodedJSON().write(to: url, options: [.atomic, .completeFileProtection])
diagnosticFileURL = url
return url
}
func connect() {
if transportActive {
client.disconnect()
@@ -84,6 +117,11 @@ final class AppModel: ObservableObject {
}
func suspendForPrivacy() {
if visibleDepthSession.state == .requestingPermission ||
visibleDepthSession.state == .calibration ||
visibleDepthSession.state == .wallScan {
visibleDepthSession.cancel()
}
guard isConnected || frame != nil else { return }
client.disconnect()
}
@@ -145,3 +183,15 @@ final class AppModel: ObservableObject {
frame = nil
}
}
enum DiagnosticExportError: LocalizedError {
case consentRequired
case noDiagnostic
var errorDescription: String? {
switch self {
case .consentRequired: return "Explicit export consent is required."
case .noDiagnostic: return "Complete or cancel a validation run before exporting."
}
}
}

View File

@@ -6,12 +6,20 @@ import SwiftUI
struct ContentView: View {
@ObservedObject var model: AppModel
@Environment(\.scenePhase) private var scenePhase
@State private var exportConsent = false
@State private var diagnosticURL: URL?
@State private var exportError: String?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 18) {
maturityCard
onboardingCard
visibleDepthCard
Text("NLOS MONITOR")
.font(.caption.bold().monospaced())
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
connectionCard
statusCard
visualizationCard
@@ -30,16 +38,33 @@ struct ContentView: View {
}
}
private var maturityCard: some View {
card(title: "Software preview") {
Text("The software path is implemented and locally validated. Research readiness remains blocked on these evidence gates:")
private var onboardingCard: some View {
card(title: "Beta tester setup") {
Label("Validate your iPhone's public ARKit LiDAR capabilities before opening the NLOS monitor.", systemImage: "iphone.gen3.radiowaves.left.and.right")
.font(.subheadline)
maturityGate("macOS native compilation")
maturityGate("Physical VL53L8CH reproduction at 27 fps or better")
maturityGate("Measured CSI fusion improvement of at least 25 percent")
Text("Builds, simulators, and synthetic replay do not close hardware or measured-fusion evidence gates.")
Text("This test measures only visible surfaces. Every result is labeled direct_depth and is never presented as around the corner evidence.")
.font(.caption.bold())
.foregroundStyle(.secondary)
.foregroundStyle(.orange)
Link(destination: URL(string: "https://ruview-nlos.ruv.chatgpt.site")!) {
Label("Open the visual explainer", systemImage: "safari")
}
Link(destination: URL(string: "https://github.com/ruvnet/RuView/issues/1690")!) {
Label("Read the test guide and provide feedback", systemImage: "bubble.left.and.exclamationmark.bubble.right")
}
}
}
private var visibleDepthCard: some View {
card(title: "Visible depth validation") {
VisibleDepthValidationView(
session: model.visibleDepthSession,
exportConsent: $exportConsent,
diagnosticURL: $diagnosticURL,
exportError: $exportError,
start: model.startVisibleDepthValidation,
cancel: model.cancelVisibleDepthValidation,
prepareExport: model.prepareDiagnosticExport
)
}
}
@@ -243,12 +268,6 @@ struct ContentView: View {
.font(.subheadline)
}
private func maturityGate(_ title: String) -> some View {
Label("OPEN · \(title)", systemImage: "circle.dashed")
.font(.subheadline)
.foregroundStyle(.orange)
}
private func badge(_ text: String, color: Color) -> some View {
Text(text)
.font(.caption2.bold().monospaced())
@@ -274,3 +293,149 @@ struct ContentView: View {
.clipShape(RoundedRectangle(cornerRadius: 18))
}
}
private struct VisibleDepthValidationView: View {
@ObservedObject var session: VisibleDepthValidationSession
@Binding var exportConsent: Bool
@Binding var diagnosticURL: URL?
@Binding var exportError: String?
let start: () -> Void
let cancel: () -> Void
let prepareExport: (Bool) throws -> URL
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("DIRECT_DEPTH")
.font(.caption.bold().monospaced())
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.orange.opacity(0.15), in: Capsule())
.foregroundStyle(.orange)
Spacer()
Text(phaseLabel)
.font(.caption.bold().monospacedDigit())
}
Text(session.statusMessage)
.font(.subheadline)
if isRunning {
ProgressView(value: phaseProgress)
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
metric("FPS", String(format: "%.1f", session.metrics.fps))
metric("Depth coverage", "\(Int(session.metrics.depthCoverage * 100))%")
metric("Tracking", session.metrics.trackingState)
metric("Movement", String(format: "%.3f m/s", session.metrics.movementMetersPerSecond))
metric("Thermal", session.metrics.thermalState)
metric("Remaining", "\(session.metrics.phaseSecondsRemaining)s")
}
Button("Cancel validation", role: .cancel, action: cancel)
.buttonStyle(.bordered)
} else {
Button {
exportConsent = false
diagnosticURL = nil
exportError = nil
start()
} label: {
Label(session.diagnostic == nil ? "Start 45 second validation" : "Run again", systemImage: "sensor.tag.radiowaves.forward")
}
.buttonStyle(.borderedProminent)
}
if session.diagnostic != nil {
Divider()
diagnosticPreview
Toggle(isOn: $exportConsent) {
VStack(alignment: .leading, spacing: 2) {
Text("I choose to export aggregate diagnostics")
Text("The JSON contains no images, raw depth, endpoint, token, or raw samples.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.onChange(of: exportConsent) { consent in
if !consent { diagnosticURL = nil }
}
if let diagnosticURL {
ShareLink(item: diagnosticURL) {
Label("Share diagnostic JSON", systemImage: "square.and.arrow.up")
}
.buttonStyle(.borderedProminent)
} else {
Button("Prepare local JSON") {
do {
diagnosticURL = try prepareExport(exportConsent)
exportError = nil
} catch {
exportError = error.localizedDescription
}
}
.buttonStyle(.bordered)
.disabled(!exportConsent)
}
if let exportError {
Text(exportError).font(.caption).foregroundStyle(.red)
}
Link("Open issue 1690 to submit feedback", destination: URL(string: "https://github.com/ruvnet/RuView/issues/1690")!)
.font(.caption)
}
}
}
private var isRunning: Bool {
session.state == .requestingPermission || session.state == .calibration || session.state == .wallScan
}
private var phaseLabel: String {
switch session.state {
case .idle: return "READY"
case .requestingPermission: return "PERMISSION"
case .calibration: return "CALIBRATION 15S"
case .wallScan: return "WALL SCAN 30S"
case .completed: return "COMPLETED"
case .cancelled: return "CANCELLED"
case .failed: return "FAILED"
}
}
private var phaseProgress: Double {
let total = session.state == .calibration ? 15.0 : 30.0
return max(0, min(1, (total - Double(session.metrics.phaseSecondsRemaining)) / total))
}
private func metric(_ title: String, _ value: String) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
Text(value).font(.subheadline.bold().monospacedDigit()).lineLimit(1).minimumScaleFactor(0.7)
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(uiColor: .tertiarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
}
@ViewBuilder
private var diagnosticPreview: some View {
if let diagnostic = session.diagnostic {
VStack(alignment: .leading, spacing: 4) {
Text("Diagnostic preview")
.font(.subheadline.bold())
Text("Evidence: \(diagnostic.evidenceLabel.rawValue)")
Text("Physical NLOS: \(diagnostic.physicalNLOSStatus)")
Text("Permission: \(diagnostic.cameraPermission)")
Text("Phases: \(diagnostic.phases.count) aggregate summaries")
if let byteCount = try? diagnostic.encodedJSON().count {
Text("Encoded size: \(byteCount) bytes of 65,536 maximum")
}
Text("Raw sensor export: false")
}
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(uiColor: .tertiarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
}
}
}

View File

@@ -24,6 +24,8 @@
<true/>
<key>NSLocalNetworkUsageDescription</key>
<string>RuView connects to an explicitly configured, authenticated NLOS processing server on your network.</string>
<key>NSCameraUsageDescription</key>
<string>RuView uses the camera and LiDAR only during an explicit visible depth validation. Images and raw depth samples are never saved.</string>
<key>UILaunchScreen</key>
<dict/>
<key>UISupportedInterfaceOrientations</key>

View File

@@ -1,13 +1,13 @@
# RuView NLOS for iOS
RuView NLOS is a native SwiftUI monitor for authenticated hidden target hypotheses produced by the RuView consumer time of flight pipeline. It is deliberately a display and transport adapter. It does not claim that Apple LiDAR or ARKit can perform optical non line of sight reconstruction.
RuView NLOS is a native SwiftUI beta tester assistant and monitor for authenticated hidden target hypotheses produced by the RuView consumer time of flight pipeline. The first screen guides a tester through a real ARKit visible depth validation before the monitor. It does not claim that Apple LiDAR or ARKit can perform optical non line of sight reconstruction.
The package has two libraries:
| Product | Responsibility |
|---|---|
| `RuViewNLOSCore` | Typed wire model, strict validation, freshness policy, sequence and replay guard, secure endpoint validation |
| `RuViewNLOSApple` | Apple capability probe, Keychain credential storage, authenticated WebSocket transport |
| `RuViewNLOSCore` | Typed wire model, strict validation, freshness policy, sequence and replay guard, secure endpoint validation, bounded diagnostic model and aggregate phase metrics |
| `RuViewNLOSApple` | Apple capability probe, active ARKit visible depth validator, Keychain credential storage, authenticated WebSocket transport |
`RuViewNLOS.xcodeproj` contains the directly buildable iOS SwiftUI app and links both local package products.
@@ -38,7 +38,53 @@ The native probe reports these capabilities separately:
| World pose | Probed with `ARWorldTrackingConfiguration.isSupported` | Motion and registration context only |
| Raw photon timing histograms | Reported unavailable | Required from an external supported transient sensor for this pipeline |
The app never upgrades ARKit depth, mesh, or pose into optical NLOS evidence. The current monitor performs only static capability checks, does not start an `ARSession`, and does not request camera permission.
The app never upgrades ARKit depth, mesh, or pose into optical NLOS evidence. The beta setup starts an `ARSession` only after the tester presses the start button and grants camera permission. It uses public `ARWorldTrackingConfiguration`, scene depth, camera pose, and tracking state APIs.
## Beta tester flow
The opening setup card links to the [interactive explainer](https://ruview-nlos.ruv.chatgpt.site) and [feedback issue 1690](https://github.com/ruvnet/RuView/issues/1690). The run takes 45 seconds after ARKit begins delivering frames:
1. Press **Start 45 second validation**. No sensor access begins before this action.
2. Grant camera permission. The camera is required by ARKit, but images are never displayed, stored, exported, or uploaded.
3. For 15 seconds, point at a well lit, textured, directly visible surface and move the phone slowly. This calibrates world tracking and visible depth coverage.
4. For 30 seconds, keep a directly visible wall in frame and move slowly from side to side. This tests sustained scene depth and pose stability.
5. Watch live frames per second, depth coverage, ARKit tracking state, movement in metres per second, thermal state, and remaining time.
6. On completion, optionally enable the explicit export consent toggle, prepare the local JSON, and use the iOS share sheet. Share it with issue 1690 only if you choose to do so.
Cancellation and ARKit interruption stop the session, preserve only bounded aggregate phase summaries, and produce a locally shareable cancellation or failure diagnostic. A new run always uses a new random session identifier.
Every validation result has evidence label `direct_depth`. It is visible surface evidence only and never NLOS evidence.
### Diagnostic contract
The JSON is capped at 64 KiB and contains only:
* Random session identifier
* Creation time
* Device model family
* OS and app versions
* Public capability flags
* At most two aggregate phase summaries
* Peak coarse thermal state and camera permission outcome
* Local validation and export consent flags
* Completion status and a bounded failure reason
* The invariant evidence label `direct_depth`
* The invariant physical NLOS status `blocked_raw_transients_unavailable`
It contains no image, raw depth map, camera transform, raw sample, hostname, endpoint, credential, token, or analytics identifier. The app has no upload endpoint. Preparing an export writes only this diagnostic JSON to the app's protected temporary directory so iOS can present its local share sheet.
### Troubleshooting
| Symptom | Likely cause | Action |
|---|---|---|
| Scene depth unavailable | Device has no supported LiDAR scene depth API | Use a LiDAR equipped iPhone Pro or iPad Pro and confirm with the capability card |
| Camera permission declined | Permission was denied or restricted | Open Settings, select RuView NLOS, enable Camera, return, and start a new run |
| Tracking says `limited_insufficient_features` | Blank wall, darkness, or too little texture | Include a textured visible object at the wall edge and improve room lighting |
| Tracking says `limited_excessive_motion` | Phone movement is too fast | Move at roughly 5 to 15 centimetres per second |
| Depth coverage is near zero | Reflective, transparent, distant, or poorly lit surface | Use a matte wall within approximately 0.5 to 4 metres |
| Thermal state is serious or critical | Sustained sensing has heated the device | Cancel, let the phone cool for 5 to 10 minutes, then retry without a case |
| Session interrupted | App backgrounded, phone call, or ARKit interruption | Keep the app foregrounded and start a new run |
| Share button is unavailable | Export consent is off or no result exists | Complete or cancel a run, enable the consent toggle, then prepare the JSON |
## Security model
@@ -48,14 +94,14 @@ On Apple platforms, the token is stored as a generic password with `kSecAttrAcce
The visualization is advisory. It must not directly trigger physical actuation or safety critical decisions.
The app has no analytics or position telemetry and its privacy manifest declares no tracking or collected data. Track frames remain in memory only and are replaced by the newest valid frame. Leaving the active foreground disconnects the stream and clears track state; the visualization is also marked privacy sensitive for system snapshots.
The app has no analytics or position telemetry and its privacy manifest declares no tracking or collected data. ARKit images, depth maps, and poses remain transient in memory and are never persisted. Only aggregate numeric phase summaries can be exported after explicit opt in. Track frames remain in memory only and are replaced by the newest valid frame. Leaving the active foreground disconnects the monitor stream and clears track state; the visualization is also marked privacy sensitive for system snapshots.
## Build and test
Requirements:
1. Swift 5.9 or newer for the package tests.
2. Xcode 15 or newer for the iOS app.
2. Xcode 15 or newer for local development; Xcode 26 or newer for App Store Connect uploads after Apple's April 2026 requirement.
3. iOS 16 or newer for deployment.
Run the deterministic protocol and security tests on macOS or Linux:
@@ -78,8 +124,8 @@ xcodebuild \
build
```
For a physical iPhone, open `RuViewNLOS.xcodeproj`, choose a development team and a unique bundle identifier, then build to the device. Enter an explicitly provisioned `wss` track endpoint and pairing token. Do not put the token in the endpoint query string.
For a physical iPhone, open `RuViewNLOS.xcodeproj`, choose a development team and a unique bundle identifier, then build to the device. Complete the opening visible depth validation before configuring the optional monitor. Enter an explicitly provisioned `wss` track endpoint and pairing token. Do not put the token in the endpoint query string.
## Validation limits
A successful Swift test or simulator build is software evidence only. It is not evidence that Apple hardware exposes photon timing histograms and it is not a reproduction of the MIT consumer NLOS result. Real hardware validation requires both an external supported time of flight sensor and captured RuView server output with reviewed calibration and provenance. The simulator normally reports ARKit sensor capabilities as unavailable.
A successful Swift test, simulator build, or completed `direct_depth` run is software and visible depth evidence only. It is not evidence that Apple hardware exposes photon timing histograms and it is not a reproduction of the MIT consumer NLOS result. Real NLOS hardware validation requires both an external supported time of flight sensor and captured RuView server output with reviewed calibration and provenance. The simulator normally reports ARKit sensor capabilities as unavailable.

View File

@@ -35,6 +35,18 @@ public struct AppleNLOSCapabilityReport: Equatable, Sendable {
}
}
public extension AppleNLOSCapabilityReport {
var visibleDepthDiagnosticFlags: VisibleDepthCapabilityFlags {
VisibleDepthCapabilityFlags(
worldTracking: worldPose == .available,
sceneDepth: sceneDepth == .available,
smoothedSceneDepth: smoothedSceneDepth == .available,
sceneMesh: sceneMesh == .available,
rawPhotonHistograms: false
)
}
}
public enum AppleCapabilityProbe {
public static func probe() -> AppleNLOSCapabilityReport {
#if canImport(ARKit)

View File

@@ -0,0 +1,348 @@
import Foundation
import RuViewNLOSCore
#if canImport(ARKit) && canImport(AVFoundation) && canImport(Combine)
@preconcurrency import ARKit
@preconcurrency import AVFoundation
import Combine
import CoreVideo
import simd
public enum VisibleDepthRunState: Equatable {
case idle
case requestingPermission
case calibration
case wallScan
case completed
case cancelled
case failed
}
public struct VisibleDepthLiveMetrics: Equatable {
public let fps: Double
public let depthCoverage: Double
public let trackingState: String
public let movementMetersPerSecond: Double
public let thermalState: String
public let phaseSecondsRemaining: Int
public init(
fps: Double = 0,
depthCoverage: Double = 0,
trackingState: String = "unavailable",
movementMetersPerSecond: Double = 0,
thermalState: String = "unknown",
phaseSecondsRemaining: Int = 15
) {
self.fps = fps
self.depthCoverage = depthCoverage
self.trackingState = trackingState
self.movementMetersPerSecond = movementMetersPerSecond
self.thermalState = thermalState
self.phaseSecondsRemaining = phaseSecondsRemaining
}
}
@MainActor
public final class VisibleDepthValidationSession: NSObject, ObservableObject {
@Published public private(set) var state: VisibleDepthRunState = .idle
@Published public private(set) var metrics = VisibleDepthLiveMetrics()
@Published public private(set) var diagnostic: VisibleDepthDiagnostic?
@Published public private(set) var statusMessage = "Ready for an explicit local validation run."
private let session = ARSession()
private var sessionId = UUID()
private var phaseStartTimestamp: Double?
private var previousFrameTimestamp: Double?
private var previousPosition: SIMD3<Float>?
private var calibration = VisibleDepthPhaseAccumulator(phase: .calibration, plannedDurationSeconds: 15)
private var wallScan = VisibleDepthPhaseAccumulator(phase: .wallScan, plannedDurationSeconds: 30)
private var deviceModelFamily = "Apple mobile device"
private var osVersion = "unknown"
private var appVersion = "unknown"
private var exportConsent = false
private var cameraPermission = "not_requested"
private var phaseStartUptime: TimeInterval?
private var lastFrameUptime: TimeInterval?
private var timerTask: Task<Void, Never>?
public override init() {
super.init()
session.delegate = self
}
public func start(
deviceModelFamily: String,
osVersion: String,
appVersion: String
) {
guard state != .requestingPermission && state != .calibration && state != .wallScan else { return }
self.deviceModelFamily = deviceModelFamily
self.osVersion = osVersion
self.appVersion = appVersion
resetRun()
guard ARWorldTrackingConfiguration.isSupported,
ARWorldTrackingConfiguration.supportsFrameSemantics(.sceneDepth) ||
ARWorldTrackingConfiguration.supportsFrameSemantics(.smoothedSceneDepth)
else {
fail("This device does not expose ARKit scene depth. Use a LiDAR equipped iPhone Pro or iPad Pro.")
return
}
state = .requestingPermission
statusMessage = "Waiting for camera permission. No image will be saved."
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
cameraPermission = "granted"
beginARSession()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
Task { @MainActor in
self?.cameraPermission = granted ? "granted" : "denied"
if granted { self?.beginARSession() }
else { self?.fail("Camera permission was declined. Enable it in Settings to run visible depth validation.") }
}
}
case .restricted:
cameraPermission = "restricted"
fail("Camera permission is restricted on this device. Ask the device administrator before retrying.")
default:
cameraPermission = "denied"
fail("Camera permission is unavailable. Enable it in Settings to run visible depth validation.")
}
}
public func cancel() {
guard state == .requestingPermission || state == .calibration || state == .wallScan else { return }
session.pause()
timerTask?.cancel()
timerTask = nil
state = .cancelled
statusMessage = "Validation cancelled. Aggregate metrics are available locally; no sensor samples were saved."
finish(status: "cancelled", failureReason: nil)
}
public func setExportConsent(_ consent: Bool) {
exportConsent = consent
rebuildDiagnosticIfFinished()
}
private func resetRun() {
session.pause()
sessionId = UUID()
phaseStartTimestamp = nil
previousFrameTimestamp = nil
previousPosition = nil
calibration = VisibleDepthPhaseAccumulator(phase: .calibration, plannedDurationSeconds: 15)
wallScan = VisibleDepthPhaseAccumulator(phase: .wallScan, plannedDurationSeconds: 30)
metrics = VisibleDepthLiveMetrics()
diagnostic = nil
exportConsent = false
cameraPermission = "not_requested"
phaseStartUptime = nil
lastFrameUptime = nil
timerTask?.cancel()
timerTask = nil
state = .idle
}
private func beginARSession() {
guard state == .requestingPermission else { return }
let configuration = ARWorldTrackingConfiguration()
if ARWorldTrackingConfiguration.supportsFrameSemantics(.smoothedSceneDepth) {
configuration.frameSemantics = .smoothedSceneDepth
} else {
configuration.frameSemantics = .sceneDepth
}
configuration.worldAlignment = .gravity
state = .calibration
phaseStartUptime = ProcessInfo.processInfo.systemUptime
statusMessage = "Calibration: point at a visible textured surface and move slowly for 15 seconds."
session.run(configuration, options: [.resetTracking, .removeExistingAnchors])
startTimer()
}
private func consume(_ frame: ARFrame) {
guard state == .calibration || state == .wallScan else { return }
if phaseStartTimestamp == nil { phaseStartTimestamp = frame.timestamp }
lastFrameUptime = ProcessInfo.processInfo.systemUptime
let delta = max(0.0001, frame.timestamp - (previousFrameTimestamp ?? frame.timestamp - (1.0 / 60.0)))
let fps = min(240, 1.0 / delta)
let position = SIMD3<Float>(
frame.camera.transform.columns.3.x,
frame.camera.transform.columns.3.y,
frame.camera.transform.columns.3.z
)
let movement = previousPosition.map { Double(simd_distance(position, $0)) / delta } ?? 0
let coverage = Self.depthCoverage(frame.smoothedSceneDepth?.depthMap ?? frame.sceneDepth?.depthMap)
let tracking = Self.trackingDescription(frame.camera.trackingState)
let newMetrics = VisibleDepthLiveMetrics(
fps: fps,
depthCoverage: coverage,
trackingState: tracking,
movementMetersPerSecond: movement,
thermalState: Self.thermalDescription(ProcessInfo.processInfo.thermalState),
phaseSecondsRemaining: metrics.phaseSecondsRemaining
)
metrics = newMetrics
if state == .calibration {
calibration.add(timestamp: frame.timestamp, fps: fps, depthCoverage: coverage, movementMetersPerSecond: movement, trackingState: tracking, thermalState: newMetrics.thermalState)
} else {
wallScan.add(timestamp: frame.timestamp, fps: fps, depthCoverage: coverage, movementMetersPerSecond: movement, trackingState: tracking, thermalState: newMetrics.thermalState)
}
previousFrameTimestamp = frame.timestamp
previousPosition = position
}
private func fail(_ reason: String) {
session.pause()
timerTask?.cancel()
timerTask = nil
state = .failed
statusMessage = reason
finish(status: "failed", failureReason: reason)
}
private func finish(status: String, failureReason: String?) {
let report = AppleCapabilityProbe.probe()
diagnostic = VisibleDepthDiagnostic(
sessionId: sessionId,
deviceModelFamily: deviceModelFamily,
osVersion: osVersion,
appVersion: appVersion,
capabilities: report.visibleDepthDiagnosticFlags,
phases: [calibration.summary(), wallScan.summary()],
consent: .init(localValidation: true, diagnosticExport: exportConsent),
cameraPermission: cameraPermission,
completionStatus: status,
failureReason: failureReason
)
}
private func rebuildDiagnosticIfFinished() {
guard let diagnostic else { return }
finish(status: diagnostic.completionStatus, failureReason: diagnostic.failureReason)
}
private func startTimer() {
timerTask?.cancel()
timerTask = Task { @MainActor [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 250_000_000)
guard let self else { return }
self.advanceTimer(now: ProcessInfo.processInfo.systemUptime)
guard self.state == .calibration || self.state == .wallScan else { return }
}
}
}
private func advanceTimer(now: TimeInterval) {
guard state == .calibration || state == .wallScan,
let phaseStartUptime
else { return }
let duration = state == .calibration ? 15.0 : 30.0
let elapsed = max(0, now - phaseStartUptime)
metrics = VisibleDepthLiveMetrics(
fps: metrics.fps,
depthCoverage: metrics.depthCoverage,
trackingState: metrics.trackingState,
movementMetersPerSecond: metrics.movementMetersPerSecond,
thermalState: Self.thermalDescription(ProcessInfo.processInfo.thermalState),
phaseSecondsRemaining: max(0, Int(ceil(duration - elapsed)))
)
guard elapsed >= duration else { return }
let summary = state == .calibration ? calibration.summary() : wallScan.summary()
guard summary.frameCount >= 15, summary.averageDepthCoverage > 0 else {
fail("Visible depth frames were unavailable or empty. Face a matte surface one to two metres away and retry.")
return
}
guard let lastFrameUptime, now - lastFrameUptime <= 2 else {
fail("The ARKit frame stream stopped before this phase completed. Keep the app active and retry.")
return
}
if state == .calibration {
state = .wallScan
self.phaseStartUptime = now
phaseStartTimestamp = nil
previousFrameTimestamp = nil
previousPosition = nil
metrics = VisibleDepthLiveMetrics(phaseSecondsRemaining: 30)
statusMessage = "Wall scan: keep the visible wall in frame and move slowly side to side for 30 seconds."
} else {
session.pause()
timerTask?.cancel()
timerTask = nil
state = .completed
statusMessage = "Visible depth validation complete. Results remain labeled direct_depth, never NLOS."
finish(status: "completed", failureReason: nil)
}
}
private static func depthCoverage(_ buffer: CVPixelBuffer?) -> Double {
guard let buffer else { return 0 }
CVPixelBufferLockBaseAddress(buffer, .readOnly)
defer { CVPixelBufferUnlockBaseAddress(buffer, .readOnly) }
guard CVPixelBufferGetPixelFormatType(buffer) == kCVPixelFormatType_DepthFloat32,
let address = CVPixelBufferGetBaseAddress(buffer)
else { return 0 }
let width = CVPixelBufferGetWidth(buffer)
let height = CVPixelBufferGetHeight(buffer)
let stride = CVPixelBufferGetBytesPerRow(buffer) / MemoryLayout<Float32>.stride
let pixels = address.assumingMemoryBound(to: Float32.self)
var valid = 0
var measured = 0
for y in Swift.stride(from: 0, to: height, by: 8) {
for x in Swift.stride(from: 0, to: width, by: 8) {
let value = pixels[(y * stride) + x]
if value.isFinite && value > 0 { valid += 1 }
measured += 1
}
}
return measured == 0 ? 0 : Double(valid) / Double(measured)
}
private static func trackingDescription(_ state: ARCamera.TrackingState) -> String {
switch state {
case .normal: return "normal"
case .notAvailable: return "not_available"
case let .limited(reason):
switch reason {
case .excessiveMotion: return "limited_excessive_motion"
case .insufficientFeatures: return "limited_insufficient_features"
case .initializing: return "limited_initializing"
case .relocalizing: return "limited_relocalizing"
@unknown default: return "limited_unknown"
}
}
}
private static func thermalDescription(_ state: ProcessInfo.ThermalState) -> String {
switch state {
case .nominal: return "nominal"
case .fair: return "fair"
case .serious: return "serious"
case .critical: return "critical"
@unknown default: return "unknown"
}
}
}
extension VisibleDepthValidationSession: ARSessionDelegate {
nonisolated public func session(_ session: ARSession, didUpdate frame: ARFrame) {
Task { @MainActor [weak self] in self?.consume(frame) }
}
nonisolated public func session(_ session: ARSession, didFailWithError error: Error) {
Task { @MainActor [weak self] in self?.fail("ARKit validation stopped: \(error.localizedDescription)") }
}
nonisolated public func sessionWasInterrupted(_ session: ARSession) {
Task { @MainActor [weak self] in self?.fail("ARKit validation was interrupted. Start a new run when the app is active.") }
}
}
#endif

View File

@@ -0,0 +1,210 @@
import Foundation
public enum VisibleDepthEvidenceLabel: String, Codable, Sendable {
case directDepth = "direct_depth"
}
public enum VisibleDepthPhase: String, Codable, Sendable {
case calibration
case wallScan = "wall_scan"
}
public struct VisibleDepthCapabilityFlags: Codable, Equatable, Sendable {
public let worldTracking: Bool
public let sceneDepth: Bool
public let smoothedSceneDepth: Bool
public let sceneMesh: Bool
public let rawPhotonHistograms: Bool
public init(
worldTracking: Bool,
sceneDepth: Bool,
smoothedSceneDepth: Bool,
sceneMesh: Bool,
rawPhotonHistograms: Bool = false
) {
self.worldTracking = worldTracking
self.sceneDepth = sceneDepth
self.smoothedSceneDepth = smoothedSceneDepth
self.sceneMesh = sceneMesh
self.rawPhotonHistograms = rawPhotonHistograms
}
}
public struct VisibleDepthPhaseSummary: Codable, Equatable, Sendable {
public let phase: VisibleDepthPhase
public let plannedDurationSeconds: Int
public let observedDurationSeconds: Double
public let frameCount: Int
public let averageFPS: Double
public let averageDepthCoverage: Double
public let averageMovementMetersPerSecond: Double
public let finalTrackingState: String
public let peakThermalState: String
public init(
phase: VisibleDepthPhase,
plannedDurationSeconds: Int,
observedDurationSeconds: Double,
frameCount: Int,
averageFPS: Double,
averageDepthCoverage: Double,
averageMovementMetersPerSecond: Double,
finalTrackingState: String,
peakThermalState: String
) {
self.phase = phase
self.plannedDurationSeconds = plannedDurationSeconds
self.observedDurationSeconds = observedDurationSeconds
self.frameCount = frameCount
self.averageFPS = averageFPS
self.averageDepthCoverage = averageDepthCoverage
self.averageMovementMetersPerSecond = averageMovementMetersPerSecond
self.finalTrackingState = finalTrackingState
self.peakThermalState = peakThermalState
}
}
public struct VisibleDepthConsentFlags: Codable, Equatable, Sendable {
public let localValidation: Bool
public let diagnosticExport: Bool
public let rawSensorExport: Bool
public init(localValidation: Bool, diagnosticExport: Bool) {
self.localValidation = localValidation
self.diagnosticExport = diagnosticExport
self.rawSensorExport = false
}
}
public struct VisibleDepthDiagnostic: Codable, Equatable, Sendable {
public static let schema = "ruview.ios.visible-depth-diagnostic.v1"
public let schema: String
public let sessionId: String
public let createdAt: Date
public let deviceModelFamily: String
public let osVersion: String
public let appVersion: String
public let capabilities: VisibleDepthCapabilityFlags
public let phases: [VisibleDepthPhaseSummary]
public let consent: VisibleDepthConsentFlags
public let evidenceLabel: VisibleDepthEvidenceLabel
public let physicalNLOSStatus: String
public let cameraPermission: String
public let completionStatus: String
public let failureReason: String?
public init(
sessionId: UUID,
createdAt: Date = Date(),
deviceModelFamily: String,
osVersion: String,
appVersion: String,
capabilities: VisibleDepthCapabilityFlags,
phases: [VisibleDepthPhaseSummary],
consent: VisibleDepthConsentFlags,
cameraPermission: String,
completionStatus: String,
failureReason: String? = nil
) {
self.schema = Self.schema
self.sessionId = sessionId.uuidString.lowercased()
self.createdAt = createdAt
self.deviceModelFamily = String(deviceModelFamily.prefix(80))
self.osVersion = String(osVersion.prefix(40))
self.appVersion = String(appVersion.prefix(40))
self.capabilities = capabilities
self.phases = Array(phases.prefix(2))
self.consent = consent
self.evidenceLabel = .directDepth
self.physicalNLOSStatus = "blocked_raw_transients_unavailable"
self.cameraPermission = String(cameraPermission.prefix(24))
self.completionStatus = String(completionStatus.prefix(24))
self.failureReason = failureReason.map { String($0.prefix(240)) }
}
public func encodedJSON() throws -> Data {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
let data = try encoder.encode(self)
guard data.count <= 64 * 1_024 else {
throw VisibleDepthDiagnosticError.packageTooLarge
}
return data
}
}
public enum VisibleDepthDiagnosticError: Error, Equatable {
case packageTooLarge
}
public struct VisibleDepthPhaseAccumulator: Sendable {
public let phase: VisibleDepthPhase
public let plannedDurationSeconds: Int
private var firstTimestamp: Double?
private var lastTimestamp: Double?
private var frameCount = 0
private var fpsTotal = 0.0
private var coverageTotal = 0.0
private var movementTotal = 0.0
private var finalTrackingState = "unavailable"
private var peakThermalState = "unknown"
public init(phase: VisibleDepthPhase, plannedDurationSeconds: Int) {
self.phase = phase
self.plannedDurationSeconds = plannedDurationSeconds
}
public mutating func add(
timestamp: Double,
fps: Double,
depthCoverage: Double,
movementMetersPerSecond: Double,
trackingState: String,
thermalState: String
) {
guard timestamp.isFinite else { return }
if firstTimestamp == nil { firstTimestamp = timestamp }
lastTimestamp = timestamp
frameCount += 1
fpsTotal += Self.clampFinite(fps, upperBound: 240)
coverageTotal += Self.clampFinite(depthCoverage, upperBound: 1)
movementTotal += Self.clampFinite(movementMetersPerSecond, upperBound: 20)
finalTrackingState = String(trackingState.prefix(48))
if Self.thermalRank(thermalState) > Self.thermalRank(peakThermalState) {
peakThermalState = String(thermalState.prefix(16))
}
}
public func summary() -> VisibleDepthPhaseSummary {
let divisor = Double(max(frameCount, 1))
return VisibleDepthPhaseSummary(
phase: phase,
plannedDurationSeconds: plannedDurationSeconds,
observedDurationSeconds: max(0, (lastTimestamp ?? 0) - (firstTimestamp ?? 0)),
frameCount: frameCount,
averageFPS: fpsTotal / divisor,
averageDepthCoverage: coverageTotal / divisor,
averageMovementMetersPerSecond: movementTotal / divisor,
finalTrackingState: finalTrackingState,
peakThermalState: peakThermalState
)
}
private static func thermalRank(_ state: String) -> Int {
switch state {
case "nominal": return 1
case "fair": return 2
case "serious": return 3
case "critical": return 4
default: return 0
}
}
private static func clampFinite(_ value: Double, upperBound: Double) -> Double {
guard value.isFinite else { return 0 }
return max(0, min(value, upperBound))
}
}

View File

@@ -7,6 +7,25 @@ final class AppleCapabilityProbeTests: XCTestCase {
XCTAssertEqual(report.rawPhotonHistograms, .unavailable)
XCTAssertFalse(report.rawPhotonHistogramReason.isEmpty)
XCTAssertFalse(report.visibleDepthDiagnosticFlags.rawPhotonHistograms)
}
func testDiagnosticFlagsPreservePublicCapabilityBoundary() {
let report = AppleNLOSCapabilityReport(
sceneDepth: .available,
smoothedSceneDepth: .unavailable,
sceneMesh: .available,
worldPose: .available,
rawPhotonHistograms: .available,
rawPhotonHistogramReason: "fixture"
)
let flags = report.visibleDepthDiagnosticFlags
XCTAssertTrue(flags.worldTracking)
XCTAssertTrue(flags.sceneDepth)
XCTAssertFalse(flags.smoothedSceneDepth)
XCTAssertTrue(flags.sceneMesh)
XCTAssertFalse(flags.rawPhotonHistograms)
}
#if !canImport(ARKit)

View File

@@ -0,0 +1,92 @@
import Foundation
import XCTest
@testable import RuViewNLOSCore
final class VisibleDepthDiagnosticTests: XCTestCase {
func testDiagnosticIsBoundedAndAlwaysDirectDepth() throws {
let diagnostic = VisibleDepthDiagnostic(
sessionId: UUID(uuidString: "11111111-2222-3333-4444-555555555555")!,
createdAt: Date(timeIntervalSince1970: 1_800_000_000),
deviceModelFamily: String(repeating: "iPhone", count: 50),
osVersion: "iOS 18.0",
appVersion: "1.0 (1)",
capabilities: .init(
worldTracking: true,
sceneDepth: true,
smoothedSceneDepth: true,
sceneMesh: true
),
phases: [],
consent: .init(localValidation: true, diagnosticExport: false),
cameraPermission: "granted",
completionStatus: "completed"
)
let data = try diagnostic.encodedJSON()
let object = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
XCTAssertEqual(object["evidenceLabel"] as? String, "direct_depth")
XCTAssertNil(object["endpoint"])
XCTAssertNil(object["token"])
XCTAssertNil(object["rawSamples"])
XCTAssertEqual(object["physicalNLOSStatus"] as? String, "blocked_raw_transients_unavailable")
XCTAssertEqual(object["cameraPermission"] as? String, "granted")
XCTAssertLessThanOrEqual(data.count, 64 * 1_024)
XCTAssertEqual(diagnostic.deviceModelFamily.count, 80)
}
func testAccumulatorClampsUntrustedMetrics() {
var accumulator = VisibleDepthPhaseAccumulator(phase: .calibration, plannedDurationSeconds: 15)
accumulator.add(
timestamp: 10,
fps: 500,
depthCoverage: 2,
movementMetersPerSecond: 100,
trackingState: "normal",
thermalState: "fair"
)
accumulator.add(
timestamp: 11,
fps: -1,
depthCoverage: -1,
movementMetersPerSecond: -1,
trackingState: "limited",
thermalState: "critical"
)
let summary = accumulator.summary()
XCTAssertEqual(summary.frameCount, 2)
XCTAssertEqual(summary.observedDurationSeconds, 1)
XCTAssertEqual(summary.averageFPS, 120)
XCTAssertEqual(summary.averageDepthCoverage, 0.5)
XCTAssertEqual(summary.averageMovementMetersPerSecond, 10)
XCTAssertEqual(summary.finalTrackingState, "limited")
XCTAssertEqual(summary.peakThermalState, "critical")
}
func testAccumulatorRejectsNonFiniteTimestampAndNormalizesNonFiniteMetrics() throws {
var accumulator = VisibleDepthPhaseAccumulator(phase: .wallScan, plannedDurationSeconds: 30)
accumulator.add(
timestamp: .nan,
fps: 30,
depthCoverage: 1,
movementMetersPerSecond: 1,
trackingState: "normal",
thermalState: "nominal"
)
accumulator.add(
timestamp: 1,
fps: .infinity,
depthCoverage: .nan,
movementMetersPerSecond: -.infinity,
trackingState: "normal",
thermalState: "nominal"
)
let summary = accumulator.summary()
XCTAssertEqual(summary.frameCount, 1)
XCTAssertEqual(summary.averageFPS, 0)
XCTAssertEqual(summary.averageDepthCoverage, 0)
XCTAssertEqual(summary.averageMovementMetersPerSecond, 0)
XCTAssertNoThrow(try JSONEncoder().encode(summary))
}
}

View File

@@ -50,6 +50,27 @@ The NLOS tab is a cross-platform **track client**, not an iPhone LiDAR capture i
Unknown, expired, out-of-order, malformed, oversized, depth-only, or unauthenticated data is never presented as live NLOS. A native host can provide an ephemeral credential with `configureNlosBearerToken`, or an operator can paste a 32-to-512-character pairing credential into the masked NLOS screen input. The credential remains in memory, is sent only in the ticket request `Authorization` header, and is never persisted by this client.
### Beta tester setup
The NLOS screen now starts with a platform-aware setup card. It links directly to the [interactive explainer](https://ruview-nlos.ruv.chatgpt.site) and the [step-by-step test and feedback issue](https://github.com/ruvnet/RuView/issues/1690).
#### Native iOS through TestFlight
1. On the test iPhone or iPad, install [Apple TestFlight](https://apps.apple.com/app/testflight/id899247664).
2. Open the private RuView invitation supplied by the beta coordinator. Installing TestFlight alone does not grant access to the beta build.
3. Install RuView NLOS Beta, open the **NLOS** tab, and allow only the permissions required by the assigned test.
4. Run **USE SYNTHETIC REPLAY** first to verify rendering and provenance labels.
5. Use **CONNECT AUTHENTICATED LIVE** only when the coordinator supplies an ephemeral pairing credential. The credential remains in memory and is never stored.
#### iPhone web app through Safari
1. Open the hosted web build in Safari on the iPhone.
2. Tap **Share**, choose **Add to Home Screen**, then open the installed RuView icon.
3. Run synthetic replay or connect to an authenticated RuView reconstruction server.
4. Submit the device model, app version, evidence label, and observed result to [issue 1690](https://github.com/ruvnet/RuView/issues/1690). Do not post credentials or private captures.
The web client cannot capture ARKit LiDAR, Apple depth maps, or raw photon timing data. It is an installable viewer for synthetic replay and validated server-produced tracks. Any LiDAR-equipped iPhone Pro or iPad Pro requirement applies only to separately assigned hardware capability checks. Evidence must remain labeled `L0 SYNTHETIC`, `L1 MEASURED`, `L2 CALIBRATED`, or `L3 CORROBORATED`, with freshness shown as `FRESH`, `STALE`, or `UNKNOWN`. Depth-only input is never evidence of physical around-the-corner reconstruction.
---
## Prerequisites

View File

@@ -1,12 +1,27 @@
export default {
name: 'WiFi-DensePose',
name: 'RuView NLOS Beta',
slug: 'wifi-densepose',
version: '1.0.0',
description: 'Governed RuView NLOS beta viewer for synthetic replay and authenticated track evidence.',
orientation: 'portrait',
userInterfaceStyle: 'dark',
icon: './assets/icon.png',
backgroundColor: '#0A0E1A',
primaryColor: '#32B8C6',
ios: {
bundleIdentifier: 'com.ruvnet.wifidensepose',
supportsTablet: true,
},
android: {
package: 'com.ruvnet.wifidensepose',
},
// Use expo-env and app-level defaults from the project configuration when available.
web: {
favicon: './assets/favicon.png',
name: 'RuView NLOS Beta',
shortName: 'RuView NLOS',
lang: 'en',
themeColor: '#0A0E1A',
backgroundColor: '#0A0E1A',
display: 'standalone',
},
};

View File

@@ -1,8 +1,14 @@
import React from 'react';
import { Linking } from 'react-native';
import { fireEvent, render, screen } from '@testing-library/react-native';
import { createSyntheticNlosFrame } from '@/services/nlos.service';
import { createLiveNlosFrameFixture } from '@/testUtils/nlosFixtures';
import { ThemeProvider } from '@/theme/ThemeContext';
import {
getBetaPlatformGuidance,
NLOS_EXPLAINER_URL,
NLOS_FEEDBACK_URL,
} from '@/screens/NLOSScreen/BetaSetupCard';
const syntheticFrame = createSyntheticNlosFrame(0, 1_700_000_000_000);
const mockNlosResult: Record<string, any> = {
@@ -55,9 +61,45 @@ describe('NLOSScreen', () => {
render(<ThemeProvider><NLOSScreen /></ThemeProvider>);
expect(screen.getByText('RuView NLOS')).toBeTruthy();
expect(screen.getByText(/does not access raw iPhone LiDAR timing data/)).toBeTruthy();
expect(screen.getByTestId('nlos-maturity-boundary')).toBeTruthy();
expect(screen.getByText(/Physical VL53L8CH reproduction at 27 fps or better/)).toBeTruthy();
expect(screen.getByText(/Measured CSI fusion improvement of at least 25 percent/)).toBeTruthy();
expect(screen.getByText(/web client cannot capture ARKit LiDAR or raw timing data/)).toBeTruthy();
});
it('provides platform-specific beta setup without browser API assumptions', () => {
const ios = getBetaPlatformGuidance('ios');
const web = getBetaPlatformGuidance('web');
expect(ios.label).toBe('NATIVE IOS BETA');
expect(ios.showTestFlightButton).toBe(true);
expect(ios.steps.join(' ')).toMatch(/TestFlight/);
expect(web.label).toBe('IPHONE WEB BETA');
expect(web.showTestFlightButton).toBe(false);
expect(web.steps.join(' ')).toMatch(/Safari/);
expect(web.steps.join(' ')).toMatch(/Add to Home Screen/);
});
it('exposes the explainer, feedback issue, and evidence boundary', () => {
const { NLOSScreen } = require('@/screens/NLOSScreen');
render(<ThemeProvider><NLOSScreen /></ThemeProvider>);
expect(NLOS_EXPLAINER_URL).toBe('https://ruview-nlos.ruv.chatgpt.site');
expect(NLOS_FEEDBACK_URL).toBe('https://github.com/ruvnet/RuView/issues/1690');
expect(screen.getByRole('link', { name: 'OPEN EXPLAINER' })).toBeTruthy();
expect(screen.getByRole('link', { name: 'TEST STEPS AND FEEDBACK' })).toBeTruthy();
expect(screen.getByText(/Depth only input is never physical NLOS evidence/)).toBeTruthy();
expect(screen.getByText(/No credentials are saved by setup/)).toBeTruthy();
});
it('opens only the fixed explainer and feedback links', () => {
const openUrl = jest.spyOn(Linking, 'openURL').mockResolvedValue(undefined);
const { NLOSScreen } = require('@/screens/NLOSScreen');
render(<ThemeProvider><NLOSScreen /></ThemeProvider>);
fireEvent.press(screen.getByRole('link', { name: 'OPEN EXPLAINER' }));
fireEvent.press(screen.getByRole('link', { name: 'TEST STEPS AND FEEDBACK' }));
expect(openUrl).toHaveBeenNthCalledWith(1, NLOS_EXPLAINER_URL);
expect(openUrl).toHaveBeenNthCalledWith(2, NLOS_FEEDBACK_URL);
openUrl.mockRestore();
});
it('always watermarks synthetic replay', () => {

View File

@@ -0,0 +1,192 @@
import { Linking, Platform, Pressable, StyleSheet, View } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
export const NLOS_EXPLAINER_URL = 'https://ruview-nlos.ruv.chatgpt.site';
export const NLOS_FEEDBACK_URL = 'https://github.com/ruvnet/RuView/issues/1690';
export const TESTFLIGHT_APP_URL = 'https://apps.apple.com/app/testflight/id899247664';
export type BetaPlatform = 'ios' | 'web' | 'other';
export interface BetaPlatformGuidance {
label: string;
steps: readonly string[];
showTestFlightButton: boolean;
}
export const getBetaPlatformGuidance = (platform: BetaPlatform): BetaPlatformGuidance => {
if (platform === 'ios') {
return {
label: 'NATIVE IOS BETA',
steps: [
'Install TestFlight, then open the private RuView invitation supplied by the beta coordinator.',
'Launch RuView, open NLOS, and allow only the permissions requested for the assigned test.',
'Run synthetic replay first. Connect live only with a coordinator supplied ephemeral credential.',
],
showTestFlightButton: true,
};
}
if (platform === 'web') {
return {
label: 'IPHONE WEB BETA',
steps: [
'Open this web build in Safari on your iPhone.',
'Tap Share, choose Add to Home Screen, then open the installed RuView icon.',
'Run synthetic replay or connect to an authenticated RuView reconstruction server.',
],
showTestFlightButton: false,
};
}
return {
label: 'BETA VIEWER',
steps: [
'Use the web build for synthetic replay and authenticated RuView track viewing.',
'Use TestFlight on a supported iPhone or iPad for the native iOS beta.',
'Report the platform, app version, evidence label, and observed result in the test issue.',
],
showTestFlightButton: false,
};
};
const currentBetaPlatform = (): BetaPlatform => {
if (Platform.OS === 'ios') return 'ios';
if (Platform.OS === 'web') return 'web';
return 'other';
};
const openTrustedUrl = async (url: string): Promise<void> => {
try {
await Linking.openURL(url);
} catch {
// The platform owns any launch error UI. No link or credential is persisted.
}
};
const LinkButton = ({ label, url, primary = false }: { label: string; url: string; primary?: boolean }) => (
<Pressable
accessibilityRole="link"
accessibilityLabel={label}
accessibilityHint="Opens in your browser"
onPress={() => { void openTrustedUrl(url); }}
style={[styles.linkButton, primary && styles.linkButtonPrimary]}
>
<ThemedText preset="labelMd" style={primary ? styles.linkButtonPrimaryText : styles.linkButtonText}>
{label}
</ThemedText>
</Pressable>
);
export const BetaSetupCard = () => {
const guidance = getBetaPlatformGuidance(currentBetaPlatform());
return (
<View testID="nlos-beta-setup" style={styles.card} accessibilityLabel="RuView NLOS beta setup">
<View style={styles.headingRow}>
<ThemedText preset="labelLg">BETA SETUP</ThemedText>
<ThemedText preset="labelMd" style={styles.platformBadge}>{guidance.label}</ThemedText>
</View>
<ThemedText preset="bodyLg" style={styles.title}>Start a governed test in about five minutes</ThemedText>
<View style={styles.steps}>
{guidance.steps.map((step, index) => (
<View key={step} style={styles.stepRow}>
<ThemedText preset="labelMd" style={styles.stepNumber}>{index + 1}</ThemedText>
<ThemedText preset="bodyMd" style={styles.stepText}>{step}</ThemedText>
</View>
))}
</View>
<View style={styles.boundary}>
<ThemedText preset="labelMd" style={styles.boundaryLabel}>CAPABILITY BOUNDARY</ThemedText>
<ThemedText preset="bodyMd">
The web client cannot capture ARKit LiDAR or raw timing data. It only displays synthetic replay or validated tracks produced by a RuView server.
</ThemedText>
</View>
<ThemedText preset="bodyMd" color="textSecondary">
Compatibility: any supported device can view tracks. A LiDAR equipped iPhone Pro or iPad Pro is needed only for separately assigned hardware capability checks.
</ThemedText>
<ThemedText preset="bodyMd" color="textSecondary">
Evidence labels: L0 synthetic, L1 measured, L2 calibrated, or L3 corroborated, plus fresh, stale, or unknown. Depth only input is never physical NLOS evidence.
</ThemedText>
<View style={styles.links}>
{guidance.showTestFlightButton && (
<LinkButton label="INSTALL TESTFLIGHT" url={TESTFLIGHT_APP_URL} primary />
)}
<LinkButton label="OPEN EXPLAINER" url={NLOS_EXPLAINER_URL} primary={!guidance.showTestFlightButton} />
<LinkButton label="TEST STEPS AND FEEDBACK" url={NLOS_FEEDBACK_URL} />
</View>
<ThemedText preset="bodySm" color="textSecondary">
No credentials are saved by setup. Live pairing credentials remain in memory only and can be forgotten at any time.
</ThemedText>
</View>
);
};
const styles = StyleSheet.create({
card: {
backgroundColor: colors.surface,
borderColor: colors.accentDim,
borderWidth: 1,
borderRadius: 12,
padding: spacing.lg,
gap: spacing.md,
},
headingRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: spacing.sm,
},
platformBadge: {
color: colors.accent,
borderColor: colors.accentDim,
borderWidth: 1,
borderRadius: 999,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
},
title: { lineHeight: 23 },
steps: { gap: spacing.sm },
stepRow: { flexDirection: 'row', alignItems: 'flex-start', gap: spacing.sm },
stepNumber: {
color: colors.bg,
backgroundColor: colors.accent,
borderRadius: 999,
width: 24,
height: 24,
lineHeight: 24,
textAlign: 'center',
},
stepText: { flex: 1, lineHeight: 21 },
boundary: {
backgroundColor: 'rgba(255, 165, 2, 0.08)',
borderLeftColor: colors.warn,
borderLeftWidth: 3,
padding: spacing.md,
gap: spacing.xs,
},
boundaryLabel: { color: colors.warn },
links: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm },
linkButton: {
minHeight: 44,
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
borderColor: colors.accent,
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
linkButtonPrimary: { backgroundColor: colors.accent },
linkButtonText: { color: colors.accent, textAlign: 'center' },
linkButtonPrimaryText: { color: colors.bg, textAlign: 'center' },
});

View File

@@ -6,6 +6,7 @@ import { useNlosStream } from '@/hooks/useNlosStream';
import { colors } from '@/theme/colors';
import { spacing } from '@/theme/spacing';
import { HiddenTargetVisualization, type NlosViewMode } from './HiddenTargetVisualization';
import { BetaSetupCard } from './BetaSetupCard';
import { ProvenancePanel } from './ProvenancePanel';
const ViewModePicker = ({ value, onChange }: { value: NlosViewMode; onChange: (value: NlosViewMode) => void }) => (
@@ -75,25 +76,14 @@ export const NLOSScreen = () => {
<ThemedText preset="labelMd" style={{ color: colors.accent }}>LABS</ThemedText>
</View>
<BetaSetupCard />
<View style={styles.notice}>
<ThemedText preset="bodySm" style={{ color: colors.warn }}>
This client does not access raw iPhone LiDAR timing data. Safari and Expo display authenticated RuView track frames or visibly watermarked synthetic replay only.
</ThemedText>
</View>
<View testID="nlos-maturity-boundary" style={styles.maturityCard}>
<ThemedText preset="labelMd" style={{ color: colors.accent }}>SOFTWARE PREVIEW</ThemedText>
<ThemedText preset="bodySm">
The software path is implemented and locally validated. Research readiness remains blocked on:
</ThemedText>
<ThemedText preset="bodySm" color="textSecondary">OPEN · macOS native compilation</ThemedText>
<ThemedText preset="bodySm" color="textSecondary">OPEN · Physical VL53L8CH reproduction at 27 fps or better</ThemedText>
<ThemedText preset="bodySm" color="textSecondary">OPEN · Measured CSI fusion improvement of at least 25 percent</ThemedText>
<ThemedText preset="bodySm" color="textSecondary">
Builds, simulators, and synthetic replay do not close hardware or measured-fusion evidence gates.
</ThemedText>
</View>
<ProvenancePanel frame={frame} freshness={freshness} streamStatus={streamStatus} />
<View style={styles.visualizationCard}>
@@ -207,14 +197,6 @@ const styles = StyleSheet.create({
borderRadius: 10,
padding: spacing.md,
},
maturityCard: {
backgroundColor: colors.surface,
borderColor: colors.accentDim,
borderWidth: 1,
borderRadius: 12,
padding: spacing.md,
gap: spacing.sm,
},
visualizationCard: {
position: 'relative',
overflow: 'hidden',