diff --git a/.github/workflows/consumer-nlos-ci.yml b/.github/workflows/consumer-nlos-ci.yml index 8563ee8a..43d36e80 100644 --- a/.github/workflows/consumer-nlos-ci.yml +++ b/.github/workflows/consumer-nlos-ci.yml @@ -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 diff --git a/docs/adr/ADR-341-consumer-nlos-beta-tester-delivery-and-diagnostics.md b/docs/adr/ADR-341-consumer-nlos-beta-tester-delivery-and-diagnostics.md new file mode 100644 index 00000000..3625fc74 --- /dev/null +++ b/docs/adr/ADR-341-consumer-nlos-beta-tester-delivery-and-diagnostics.md @@ -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). diff --git a/docs/adr/README.md b/docs/adr/README.md index 5071ace4..2de7c581 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 diff --git a/docs/research/consumer-nlos-beta-test-protocol.md b/docs/research/consumer-nlos-beta-test-protocol.md new file mode 100644 index 00000000..54a616ae --- /dev/null +++ b/docs/research/consumer-nlos-beta-test-protocol.md @@ -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. diff --git a/docs/schemas/ruview-ios-visible-depth-diagnostic-v1.schema.json b/docs/schemas/ruview-ios-visible-depth-diagnostic-v1.schema.json new file mode 100644 index 00000000..e5265b33 --- /dev/null +++ b/docs/schemas/ruview-ios-visible-depth-diagnostic-v1.schema.json @@ -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 } + } + } + } +} diff --git a/ui/ios-nlos/App/AppModel.swift b/ui/ios-nlos/App/AppModel.swift index acaca034..f879d56a 100644 --- a/ui/ios-nlos/App/AppModel.swift +++ b/ui/ios-nlos/App/AppModel.swift @@ -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." + } + } +} diff --git a/ui/ios-nlos/App/ContentView.swift b/ui/ios-nlos/App/ContentView.swift index c90890c5..d25a76a5 100644 --- a/ui/ios-nlos/App/ContentView.swift +++ b/ui/ios-nlos/App/ContentView.swift @@ -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)) + } + } +} diff --git a/ui/ios-nlos/App/Info.plist b/ui/ios-nlos/App/Info.plist index a2bfbecc..339dadd5 100644 --- a/ui/ios-nlos/App/Info.plist +++ b/ui/ios-nlos/App/Info.plist @@ -24,6 +24,8 @@ NSLocalNetworkUsageDescription RuView connects to an explicitly configured, authenticated NLOS processing server on your network. + NSCameraUsageDescription + RuView uses the camera and LiDAR only during an explicit visible depth validation. Images and raw depth samples are never saved. UILaunchScreen UISupportedInterfaceOrientations diff --git a/ui/ios-nlos/README.md b/ui/ios-nlos/README.md index b3613777..559a2f1d 100644 --- a/ui/ios-nlos/README.md +++ b/ui/ios-nlos/README.md @@ -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. diff --git a/ui/ios-nlos/Sources/RuViewNLOSApple/AppleCapabilityProbe.swift b/ui/ios-nlos/Sources/RuViewNLOSApple/AppleCapabilityProbe.swift index d258c416..b85f7e44 100644 --- a/ui/ios-nlos/Sources/RuViewNLOSApple/AppleCapabilityProbe.swift +++ b/ui/ios-nlos/Sources/RuViewNLOSApple/AppleCapabilityProbe.swift @@ -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) diff --git a/ui/ios-nlos/Sources/RuViewNLOSApple/VisibleDepthValidationSession.swift b/ui/ios-nlos/Sources/RuViewNLOSApple/VisibleDepthValidationSession.swift new file mode 100644 index 00000000..9891ceb9 --- /dev/null +++ b/ui/ios-nlos/Sources/RuViewNLOSApple/VisibleDepthValidationSession.swift @@ -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? + 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? + + 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( + 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.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 diff --git a/ui/ios-nlos/Sources/RuViewNLOSCore/VisibleDepthDiagnostic.swift b/ui/ios-nlos/Sources/RuViewNLOSCore/VisibleDepthDiagnostic.swift new file mode 100644 index 00000000..6a9d0a5a --- /dev/null +++ b/ui/ios-nlos/Sources/RuViewNLOSCore/VisibleDepthDiagnostic.swift @@ -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)) + } +} diff --git a/ui/ios-nlos/Tests/RuViewNLOSAppleTests/AppleCapabilityProbeTests.swift b/ui/ios-nlos/Tests/RuViewNLOSAppleTests/AppleCapabilityProbeTests.swift index bc5a5a1c..c36ab0cf 100644 --- a/ui/ios-nlos/Tests/RuViewNLOSAppleTests/AppleCapabilityProbeTests.swift +++ b/ui/ios-nlos/Tests/RuViewNLOSAppleTests/AppleCapabilityProbeTests.swift @@ -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) diff --git a/ui/ios-nlos/Tests/RuViewNLOSCoreTests/VisibleDepthDiagnosticTests.swift b/ui/ios-nlos/Tests/RuViewNLOSCoreTests/VisibleDepthDiagnosticTests.swift new file mode 100644 index 00000000..4d123032 --- /dev/null +++ b/ui/ios-nlos/Tests/RuViewNLOSCoreTests/VisibleDepthDiagnosticTests.swift @@ -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)) + } +} diff --git a/ui/mobile/README.md b/ui/mobile/README.md index 95b1cc36..3e407a1e 100644 --- a/ui/mobile/README.md +++ b/ui/mobile/README.md @@ -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 diff --git a/ui/mobile/app.config.ts b/ui/mobile/app.config.ts index 7a62f32a..45d426a0 100644 --- a/ui/mobile/app.config.ts +++ b/ui/mobile/app.config.ts @@ -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', + }, }; diff --git a/ui/mobile/src/__tests__/screens/NLOSScreen.test.tsx b/ui/mobile/src/__tests__/screens/NLOSScreen.test.tsx index afafd65e..47944ac1 100644 --- a/ui/mobile/src/__tests__/screens/NLOSScreen.test.tsx +++ b/ui/mobile/src/__tests__/screens/NLOSScreen.test.tsx @@ -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 = { @@ -55,9 +61,45 @@ describe('NLOSScreen', () => { render(); 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(); + + 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(); + + 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', () => { diff --git a/ui/mobile/src/screens/NLOSScreen/BetaSetupCard.tsx b/ui/mobile/src/screens/NLOSScreen/BetaSetupCard.tsx new file mode 100644 index 00000000..c00dc15c --- /dev/null +++ b/ui/mobile/src/screens/NLOSScreen/BetaSetupCard.tsx @@ -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 => { + 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 }) => ( + { void openTrustedUrl(url); }} + style={[styles.linkButton, primary && styles.linkButtonPrimary]} + > + + {label} + + +); + +export const BetaSetupCard = () => { + const guidance = getBetaPlatformGuidance(currentBetaPlatform()); + + return ( + + + BETA SETUP + {guidance.label} + + + Start a governed test in about five minutes + + + {guidance.steps.map((step, index) => ( + + {index + 1} + {step} + + ))} + + + + CAPABILITY BOUNDARY + + The web client cannot capture ARKit LiDAR or raw timing data. It only displays synthetic replay or validated tracks produced by a RuView server. + + + + + Compatibility: any supported device can view tracks. A LiDAR equipped iPhone Pro or iPad Pro is needed only for separately assigned hardware capability checks. + + + Evidence labels: L0 synthetic, L1 measured, L2 calibrated, or L3 corroborated, plus fresh, stale, or unknown. Depth only input is never physical NLOS evidence. + + + + {guidance.showTestFlightButton && ( + + )} + + + + + + No credentials are saved by setup. Live pairing credentials remain in memory only and can be forgotten at any time. + + + ); +}; + +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' }, +}); diff --git a/ui/mobile/src/screens/NLOSScreen/index.tsx b/ui/mobile/src/screens/NLOSScreen/index.tsx index 2efc6840..957ed4e0 100644 --- a/ui/mobile/src/screens/NLOSScreen/index.tsx +++ b/ui/mobile/src/screens/NLOSScreen/index.tsx @@ -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 = () => { LABS + + This client does not access raw iPhone LiDAR timing data. Safari and Expo display authenticated RuView track frames or visibly watermarked synthetic replay only. - - SOFTWARE PREVIEW - - The software path is implemented and locally validated. Research readiness remains blocked on: - - OPEN · macOS native compilation - OPEN · Physical VL53L8CH reproduction at 27 fps or better - OPEN · Measured CSI fusion improvement of at least 25 percent - - Builds, simulators, and synthetic replay do not close hardware or measured-fusion evidence gates. - - - @@ -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',