initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project
This commit is contained in:
149
docs/TESTING.md
Normal file
149
docs/TESTING.md
Normal file
@@ -0,0 +1,149 @@
|
||||
# Testing Proximity Without an iPhone
|
||||
|
||||
This document explains **how to run and test the entire app using only the
|
||||
iOS Simulator** — no physical iPhone required for the core interaction loop.
|
||||
|
||||
## The strategy in one sentence
|
||||
|
||||
> **The interaction logic is fully testable in the simulator. Only the physical
|
||||
> sensors (UWB, NFC) need real hardware.**
|
||||
|
||||
We achieved this by abstracting all hardware behind protocols
|
||||
(`ProximityAdapterProtocols`) and injecting a `SimulatedAdapter` in DEBUG
|
||||
builds. `PresenceEngine` talks to the protocols, never to hardware directly,
|
||||
so it behaves identically whether it's sensing real UWB or receiving simulated
|
||||
encounters.
|
||||
|
||||
## What you can test without any device
|
||||
|
||||
| Capability | Simulator | Real iPhone |
|
||||
|---|---|---|
|
||||
| SwiftUI UI, navigation, chat | ✅ | ✅ |
|
||||
| Auth (Apple / Google) | ✅ (Apple needs bundle config) | ✅ |
|
||||
| Backend + WebSocket live loop | ✅ | ✅ |
|
||||
| Full reveal flow (wave → mutual → chat) | ✅ | ✅ |
|
||||
| Simulated encounters / crowd | ✅ | ✅ |
|
||||
| Core Location / iBeacon | ⚠️ simulated location | ✅ |
|
||||
| BLE broadcast/discovery | ⚠️ limited | ✅ |
|
||||
| **UWB (Nearby Interaction)** | ❌ | ✅ |
|
||||
| **NFC tap** | ❌ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Quick start (simulator)
|
||||
|
||||
### 1. Run the reference backend
|
||||
|
||||
The Swift client in DEBUG mode points at a local server. Start it:
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm run dev # runs on http://localhost:8080
|
||||
```
|
||||
|
||||
### 2. Run the app in the Simulator
|
||||
|
||||
1. Open `Proximity.xcodeproj` in Xcode.
|
||||
2. Select an iPhone simulator (any model).
|
||||
3. Build & run (⌘R).
|
||||
|
||||
In DEBUG builds, `AppState` automatically:
|
||||
- Points `APIClient` at `http://localhost:8080`
|
||||
- Injects `SimulatedAdapter` into the `PresenceEngine`
|
||||
|
||||
### 3. Exercise the interaction loop
|
||||
|
||||
1. **Sign in** (Apple or Google — the reference server accepts any token).
|
||||
2. Tap the big **"I'm Here"** toggle to go present.
|
||||
3. Open the **debug menu** (hammer icon, top-left).
|
||||
4. Tap **"Simulate someone walking by"** — a silhouette appears in the feed.
|
||||
5. Tap **Wave** on the silhouette.
|
||||
6. To complete the reveal, simulate the *other* side waving back — in real
|
||||
life the server pushes this over the socket. For a fully local test, you
|
||||
can drive the mutual wave via the debug tools or a second client.
|
||||
|
||||
### 4. Test the reveal moment
|
||||
|
||||
When both sides wave, `RevealView` presents full-screen — two avatars
|
||||
converging. Tap **"Say hello"** to open the E2E chat.
|
||||
|
||||
---
|
||||
|
||||
## Driving the simulation
|
||||
|
||||
The debug menu (`DebugMenu`) provides:
|
||||
|
||||
- **Simulate someone walking by** — one UWB encounter.
|
||||
- **Simulate a crowd (20 people)** — a burst of silhouettes.
|
||||
- **Auto-broadcast nearby users** — emits encounters on a timer (a "busy
|
||||
street" simulator).
|
||||
|
||||
`SimulatedAdapter` also exposes programmatic methods if you want to write
|
||||
UITests:
|
||||
|
||||
```swift
|
||||
simulatedAdapter.simulateEncounter(remoteToken: "any-token", distance: 1.0)
|
||||
simulatedAdapter.simulateCrowd(count: 20)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Two-simulator / two-client testing
|
||||
|
||||
To test the **mutual** flow (both sides), run **two simulator instances**:
|
||||
|
||||
1. Boot two simulators (e.g. iPhone 15 + iPhone 15 Pro).
|
||||
2. Run the app on both against the same local backend.
|
||||
3. In each, go present and simulate an encounter (the debug menu gives each a
|
||||
distinct token).
|
||||
4. Wave from one — the server derives each sender's own token from its
|
||||
session, records the directional edge, and when both directions exist it
|
||||
pushes `mutual` to **both** sockets. The reveal triggers on both devices
|
||||
in real time.
|
||||
|
||||
The reference server now implements the full session→token mapping, so the
|
||||
mutual reveal and E2E key exchange work end-to-end across two clients.
|
||||
|
||||
## Running the UI tests
|
||||
|
||||
The `ProximityUITests` target drives the happy path automatically:
|
||||
|
||||
```bash
|
||||
xcodebuild test \
|
||||
-project Proximity.xcodeproj \
|
||||
-scheme Proximity \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 15' \
|
||||
-only-testing:ProximityUITests/ProximityFlowUITests
|
||||
```
|
||||
|
||||
Start the reference server first (`cd server && npm run dev`).
|
||||
|
||||
---
|
||||
|
||||
## Testing on real hardware (the sensors)
|
||||
|
||||
When you're ready to test UWB / BLE / NFC for real:
|
||||
|
||||
1. **Disable the simulated adapter.** Either build a non-DEBUG configuration,
|
||||
or set `AppState.isSimulated = false`.
|
||||
2. Use two physical iPhones (iPhone 11+ for UWB).
|
||||
3. The real `UWBAdapter`, `BLEAdapter`, `BeaconAdapter` take over.
|
||||
|
||||
The rest of the app (auth, chat, reveal, backend) works identically — that's
|
||||
the point of the abstraction.
|
||||
|
||||
---
|
||||
|
||||
## What the simulator can't do (and why it's OK)
|
||||
|
||||
- **UWB**: no support in the simulator. But UWB is only the *"this exact
|
||||
person"* distance sensor — the interaction logic (handshake → reveal) is
|
||||
fully testable via simulation.
|
||||
- **NFC**: no support. NFC is a niche "tap to connect" feature, not core.
|
||||
- **BLE**: very limited in simulator. The presence *logic* is testable; the
|
||||
radio isn't.
|
||||
|
||||
Since the make-or-break mechanic — **the physical interaction moment** — is
|
||||
driven by logic + UI, not by the radio, you can validate the entire product
|
||||
experience in the simulator before ever touching real devices.
|
||||
513
docs/TESTING_GUIDE.html
Normal file
513
docs/TESTING_GUIDE.html
Normal file
@@ -0,0 +1,513 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Proximity — Testing Guide (M-Series Mac)</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1115;
|
||||
--panel: #171a21;
|
||||
--panel-2: #1e222b;
|
||||
--border: #2a2f3a;
|
||||
--text: #e6e8ee;
|
||||
--muted: #9aa3b2;
|
||||
--green: #34c759;
|
||||
--green-dim: #1f7a3d;
|
||||
--blue: #4aa8ff;
|
||||
--amber: #ffb340;
|
||||
--red: #ff5f57;
|
||||
--mono: "SF Mono", "Menlo", "Consolas", monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.65;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
.wrap { max-width: 860px; margin: 0 auto; padding: 0 24px; }
|
||||
|
||||
header {
|
||||
background: linear-gradient(135deg, #0d1f14 0%, #0f1115 60%);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 48px 0 36px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
header .wrap { display: flex; align-items: center; gap: 20px; }
|
||||
.logo {
|
||||
width: 64px; height: 64px; border-radius: 16px;
|
||||
background: radial-gradient(circle at 30% 30%, #34c759, #1f7a3d);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 30px; color: white; flex-shrink: 0;
|
||||
box-shadow: 0 8px 24px rgba(52,199,89,.35);
|
||||
}
|
||||
h1 { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; }
|
||||
.sub { color: var(--muted); font-size: 15px; margin-top: 4px; }
|
||||
|
||||
h2 {
|
||||
font-size: 21px; font-weight: 700; margin: 40px 0 16px;
|
||||
padding-bottom: 8px; border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
h2 .step-num {
|
||||
background: var(--green); color: #07130a; font-size: 13px; font-weight: 800;
|
||||
width: 26px; height: 26px; border-radius: 8px; display: inline-flex;
|
||||
align-items: center; justify-content: center; flex-shrink: 0;
|
||||
}
|
||||
h3 { font-size: 16px; font-weight: 600; margin: 22px 0 10px; color: var(--text); }
|
||||
|
||||
p { margin: 10px 0; color: var(--text); }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
.card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 12px; padding: 18px 20px; margin: 14px 0;
|
||||
}
|
||||
.card.tip { border-left: 4px solid var(--blue); }
|
||||
.card.warn { border-left: 4px solid var(--amber); }
|
||||
.card.danger { border-left: 4px solid var(--red); }
|
||||
.card.success { border-left: 4px solid var(--green); }
|
||||
|
||||
code {
|
||||
font-family: var(--mono); font-size: 13px;
|
||||
background: var(--panel-2); border: 1px solid var(--border);
|
||||
padding: 2px 6px; border-radius: 5px; color: #d7e0ee;
|
||||
}
|
||||
|
||||
pre {
|
||||
font-family: var(--mono); font-size: 13px; line-height: 1.6;
|
||||
background: #0b0d12; border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 16px 18px; overflow-x: auto; margin: 12px 0;
|
||||
}
|
||||
pre code { background: none; border: none; padding: 0; color: #c9d4e6; }
|
||||
.cmt { color: #5c6b7a; }
|
||||
|
||||
ul, ol { margin: 10px 0 10px 22px; }
|
||||
li { margin: 6px 0; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 14px; }
|
||||
th, td { text-align: left; padding: 10px 12px; border: 1px solid var(--border); }
|
||||
th { background: var(--panel-2); font-weight: 600; }
|
||||
td { background: var(--panel); }
|
||||
.yes { color: var(--green); font-weight: 600; }
|
||||
.no { color: var(--red); font-weight: 600; }
|
||||
.part { color: var(--amber); font-weight: 600; }
|
||||
|
||||
.kbd {
|
||||
font-family: var(--mono); font-size: 12px; background: var(--panel-2);
|
||||
border: 1px solid var(--border); border-bottom-width: 3px; border-radius: 6px;
|
||||
padding: 2px 7px; color: var(--text);
|
||||
}
|
||||
|
||||
.toc { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 20px 24px; }
|
||||
.toc a { color: var(--blue); text-decoration: none; }
|
||||
.toc a:hover { text-decoration: underline; }
|
||||
.toc ol { margin-left: 18px; }
|
||||
.toc li { margin: 5px 0; }
|
||||
|
||||
.check { color: var(--green); margin-right: 6px; }
|
||||
.cross { color: var(--red); margin-right: 6px; }
|
||||
.arrow { color: var(--green); font-weight: 700; }
|
||||
|
||||
footer { margin-top: 60px; padding-top: 20px; border-top: 1px solid var(--border); color: var(--muted); font-size: 13px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<div class="wrap">
|
||||
<div class="logo">📡</div>
|
||||
<div>
|
||||
<h1>Proximity — Testing Guide</h1>
|
||||
<div class="sub">Step-by-step walkthrough for an Apple Silicon (M-series) MacBook · iOS Simulator · no physical iPhone needed for the core flow</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<!-- ================= TOC ================= -->
|
||||
<div class="toc">
|
||||
<strong>Contents</strong>
|
||||
<ol>
|
||||
<li><a href="#overview">What you're testing & the big idea</a></li>
|
||||
<li><a href="#prereqs">Prerequisites (install these first)</a></li>
|
||||
<li><a href="#structure">Project structure map</a></li>
|
||||
<li><a href="#server">Start the reference backend</a></li>
|
||||
<li><a href="#xcode">Open & configure the project in Xcode</a></li>
|
||||
<li><a href="#run">Run the app in the Simulator</a></li>
|
||||
<li><a href="#walkthrough">Walk through the interaction loop</a></li>
|
||||
<li><a href="#twosim">Two-simulator mutual reveal test</a></li>
|
||||
<li><a href="#uitests">Run the automated UI tests</a></li>
|
||||
<li><a href="#reald">Testing on a real iPhone (the sensors)</a></li>
|
||||
<li><a href="#troubleshoot">Troubleshooting on M-series</a></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- ================= 1. OVERVIEW ================= -->
|
||||
<h2 id="overview"><span class="step-num">1</span> What you're testing & the big idea</h2>
|
||||
<p>
|
||||
Proximity is a social app where you can only connect with people you're
|
||||
<strong>physically near</strong>. The make-or-break mechanic is the
|
||||
<em>interaction moment</em>: silhouette → wave → mutual reveal → chat.
|
||||
</p>
|
||||
<div class="card success">
|
||||
<strong>The key insight:</strong> All the <em>interaction logic</em> — the part that makes or breaks the
|
||||
community — is fully testable in the <strong>iOS Simulator</strong>, with no iPhone. Only the physical
|
||||
<em>sensors</em> (UWB, NFC) need real hardware. We built a <code>SimulatedAdapter</code> that stands in for
|
||||
the hardware, so the app behaves identically.
|
||||
</div>
|
||||
<table>
|
||||
<tr><th>Capability</th><th>Simulator</th><th>Real iPhone</th></tr>
|
||||
<tr><td>UI, navigation, chat</td><td class="yes">✅</td><td class="yes">✅</td></tr>
|
||||
<tr><td>Auth (Apple / Google)</td><td class="yes">✅</td><td class="yes">✅</td></tr>
|
||||
<tr><td>Backend + WebSocket live loop</td><td class="yes">✅</td><td class="yes">✅</td></tr>
|
||||
<tr><td>Reveal flow (wave → mutual → chat)</td><td class="yes">✅</td><td class="yes">✅</td></tr>
|
||||
<tr><td>Simulated encounters / crowd</td><td class="yes">✅</td><td class="yes">✅</td></tr>
|
||||
<tr><td>BLE broadcast / discovery</td><td class="part">⚠️ limited</td><td class="yes">✅</td></tr>
|
||||
<tr><td>UWB (Nearby Interaction)</td><td class="no">❌</td><td class="yes">✅</td></tr>
|
||||
<tr><td>NFC tap</td><td class="no">❌</td><td class="yes">✅</td></tr>
|
||||
</table>
|
||||
|
||||
<!-- ================= 2. PREREQS ================= -->
|
||||
<h2 id="prereqs"><span class="step-num">2</span> Prerequisites</h2>
|
||||
<p>Install these before starting. On an M-series Mac, everything runs natively on Apple Silicon.</p>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0">A. Xcode (required)</h3>
|
||||
<p>Install from the Mac App Store, or run:</p>
|
||||
<pre><code>xcode-select --install
|
||||
<span class="cmt"># If you need a specific version, use Xcodes:</span>
|
||||
brew install --cask xcodes</code></pre>
|
||||
<p class="muted">You need Xcode 15 or later for Swift 5.9+ concurrency and SwiftData. Xcode 16 recommended.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0">B. Node.js (required for the reference server)</h3>
|
||||
<p>Check if you have it:</p>
|
||||
<pre><code>node --version <span class="cmt"># want v18+</span>
|
||||
npm --version</code></pre>
|
||||
<p>If not, install via Homebrew (native arm64 build):</p>
|
||||
<pre><code>brew install node</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0">C. Homebrew (recommended)</h3>
|
||||
<pre><code>/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="card tip">
|
||||
<strong>M-series note:</strong> Everything here is native arm64. You do <em>not</em> need Rosetta for this
|
||||
project. If you ever see "running under Rosetta" in Activity Monitor for a tool, it's optional and not required.
|
||||
</div>
|
||||
|
||||
<!-- ================= 3. STRUCTURE ================= -->
|
||||
<h2 id="structure"><span class="step-num">3</span> Project structure map</h2>
|
||||
<p>Here's what you're working with:</p>
|
||||
<pre><code>Nearfield_Friends/
|
||||
├── Proximity/ <span class="cmt"># iOS app (SwiftUI)</span>
|
||||
│ ├── App/ <span class="cmt"># entry point, AppState wiring</span>
|
||||
│ ├── Models/ <span class="cmt"># Encounter, Connection, DistanceTier…</span>
|
||||
│ ├── Services/
|
||||
│ │ ├── Proximity/ <span class="cmt"># PresenceEngine + adapters + SimulatedAdapter</span>
|
||||
│ │ ├── Network/ <span class="cmt"># APIClient, RealtimeClient (WebSocket)</span>
|
||||
│ │ ├── Auth/ <span class="cmt"># Apple/Google sign-in, profile import</span>
|
||||
│ │ └── Security/ <span class="cmt"># tokens, E2E crypto</span>
|
||||
│ ├── ViewModels/ <span class="cmt"># Presence, Chat, Reveal</span>
|
||||
│ └── Views/ <span class="cmt"># SwiftUI screens + DebugMenu</span>
|
||||
├── ProximityUITests/ <span class="cmt"># automated UI tests</span>
|
||||
├── Proximity.xcodeproj/ <span class="cmt"># Xcode project (generated)</span>
|
||||
├── scripts/
|
||||
│ └── generate_xcodeproj.py <span class="cmt"># regenerates the .xcodeproj</span>
|
||||
├── server/ <span class="cmt"># reference backend (Node/TS)</span>
|
||||
│ └── src/index.ts
|
||||
└── docs/
|
||||
├── backend-api-spec.md <span class="cmt"># API contract</span>
|
||||
├── TESTING.md <span class="cmt"># testing notes</span>
|
||||
└── TESTING_GUIDE.html <span class="cmt"># this file</span></code></pre>
|
||||
|
||||
<div class="card success">
|
||||
<strong>Good news:</strong> A <code>Proximity.xcodeproj</code> is now included in the repo (generated by
|
||||
<code>scripts/generate_xcodeproj.py</code>). It contains the app target, the UI test target, and a shared
|
||||
scheme — so you can open it directly in Xcode and run <code>xcodebuild test</code> with no manual setup.
|
||||
If you ever add/remove Swift files, re-run the generator to keep the project in sync.
|
||||
</div>
|
||||
|
||||
<!-- ================= 4. SERVER ================= -->
|
||||
<h2 id="server"><span class="step-num">4</span> Start the reference backend</h2>
|
||||
<p>The Swift app in DEBUG mode points at a local server on <code>localhost:8080</code>. Start it first.</p>
|
||||
|
||||
<div class="card">
|
||||
<p>Open <strong>Terminal</strong> and run:</p>
|
||||
<pre><code>cd ~/path/to/Nearfield_Friends/server
|
||||
npm install
|
||||
npm run dev</code></pre>
|
||||
<p>You should see:</p>
|
||||
<pre><code>Proximity reference server on :8080</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="card warn">
|
||||
<strong>Keep this terminal window open.</strong> The server must stay running while you test. If you close it,
|
||||
the app will still work for local UI, but the live socket (mutual reveal, real-time chat) won't.
|
||||
</div>
|
||||
|
||||
<div class="card tip">
|
||||
<strong>Quick sanity check:</strong> In a second terminal, run
|
||||
<code>curl http://localhost:8080/v1/nearby?geohash=abc&tier=1</code> — you should get <code>[]</code> back.
|
||||
</div>
|
||||
|
||||
<!-- ================= 5. XCODE ================= -->
|
||||
<h2 id="xcode"><span class="step-num">5</span> Open & configure the project in Xcode</h2>
|
||||
|
||||
<h3>5a. Open the project</h3>
|
||||
<div class="card">
|
||||
<p>The <code>Proximity.xcodeproj</code> is already generated. Open it:</p>
|
||||
<pre><code>open Proximity.xcodeproj</code></pre>
|
||||
<p>or double-click it in Finder. It includes the app target, the <code>ProximityUITests</code> target, and a
|
||||
shared scheme, so everything is ready to go.</p>
|
||||
<p class="muted">The entry point is <code>ProximityApp.swift</code> (marked <code>@main</code>).</p>
|
||||
</div>
|
||||
|
||||
<div class="card tip">
|
||||
<strong>Keeping the project in sync:</strong> If you add or remove Swift files, regenerate the project so the
|
||||
file list stays correct:
|
||||
<pre><code>python3 scripts/generate_xcodeproj.py</code></pre>
|
||||
Then re-open the project in Xcode.
|
||||
</div>
|
||||
|
||||
<h3>5b. Set the signing team (free account is fine)</h3>
|
||||
<div class="card">
|
||||
<ol>
|
||||
<li>Select the <strong>Proximity</strong> target in the project navigator.</li>
|
||||
<li>Go to <strong>Signing & Capabilities</strong>.</li>
|
||||
<li>Check <strong>"Automatically manage signing"</strong>.</li>
|
||||
<li>Pick your <strong>Team</strong> (your Apple ID — free accounts work for the simulator).</li>
|
||||
<li>Set a unique <strong>Bundle Identifier</strong>, e.g. <code>com.yourname.proximity</code>.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<h3>5c. Add required capabilities (for real devices later)</h3>
|
||||
<div class="card">
|
||||
<p>For the simulator you only strictly need these for the real-hardware build, but add them now so the
|
||||
project is ready:</p>
|
||||
<ul>
|
||||
<li><strong>Near Field Communication Tag Reading</strong> (Core NFC)</li>
|
||||
<li><strong>Bluetooth</strong> (Core Bluetooth)</li>
|
||||
<li><strong>Location</strong> (Core Location)</li>
|
||||
</ul>
|
||||
<p class="muted">The simulator ignores UWB/NFC but will use simulated location.</p>
|
||||
</div>
|
||||
|
||||
<h3>5d. Add the Info.plist usage strings</h3>
|
||||
<div class="card">
|
||||
<p>Add these to <code>Info.plist</code> so permissions work:</p>
|
||||
<pre><code>NFCReaderUsageDescription "Proximity uses NFC to connect when you tap phones."
|
||||
NSBluetoothAlwaysUsageDescription "Proximity uses Bluetooth to detect nearby friends."
|
||||
NSLocationWhenInUseUsageDescription "Proximity uses your location to find people nearby."</code></pre>
|
||||
</div>
|
||||
|
||||
<!-- ================= 6. RUN ================= -->
|
||||
<h2 id="run"><span class="step-num">6</span> Run the app in the Simulator</h2>
|
||||
|
||||
<div class="card">
|
||||
<ol>
|
||||
<li>At the top of Xcode, pick a simulator from the device dropdown
|
||||
(e.g. <strong>iPhone 15</strong> or <strong>iPhone 15 Pro</strong>).</li>
|
||||
<li>Press <span class="kbd">⌘R</span> to build & run.</li>
|
||||
<li>The first build may take a minute. The Simulator window opens with the app.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card success">
|
||||
<strong>What happens automatically in DEBUG builds:</strong>
|
||||
<ul>
|
||||
<li><span class="arrow">→</span> <code>APIClient</code> points at <code>http://localhost:8080</code></li>
|
||||
<li><span class="arrow">→</span> <code>SimulatedAdapter</code> is injected into the engine (no hardware)</li>
|
||||
<li><span class="arrow">→</span> A debug menu (hammer icon) appears in the toolbar</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card warn">
|
||||
<strong>If the app can't reach the server:</strong> the simulator shares your Mac's network, so
|
||||
<code>localhost</code> works. If you see connection errors, make sure the server from
|
||||
<a href="#server">Step 4</a> is still running, then use the debug menu's <em>"Reconnect socket"</em>.
|
||||
</div>
|
||||
|
||||
<!-- ================= 7. WALKTHROUGH ================= -->
|
||||
<h2 id="walkthrough"><span class="step-num">7</span> Walk through the interaction loop</h2>
|
||||
<p>This is the heart of the product. You'll simulate a full encounter with no hardware.</p>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="margin-top:0">Step 1 — Sign in</h3>
|
||||
<p>Tap <strong>Continue with Google</strong> (or Apple). The reference server accepts any token, so this
|
||||
succeeds immediately. You'll land on the main screen.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 2 — Go present</h3>
|
||||
<p>Tap the big circular <strong>"Tap to be Present"</strong> button. It turns green and reads
|
||||
<strong>"I'm Here"</strong>. This opens the live socket.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 3 — Simulate an encounter</h3>
|
||||
<ol>
|
||||
<li>Tap the <strong>hammer icon</strong> (top-left) to open the Debug menu.</li>
|
||||
<li>Tap <strong>"Simulate someone walking by"</strong>.</li>
|
||||
<li>Go back. A <strong>silhouette</strong> ("Someone nearby") appears in the feed.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 4 — Wave</h3>
|
||||
<p>Tap <strong>Wave</strong> on the silhouette. It changes to <strong>"Waved ✓"</strong> — you're now waiting
|
||||
for the other person.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 5 — Complete the reveal (two ways)</h3>
|
||||
<p><strong>Option A — two simulators</strong> (recommended, <a href="#twosim">see next section</a>): the server
|
||||
relays the mutual wave and the reveal triggers automatically.</p>
|
||||
<p><strong>Option B — single simulator:</strong> the reveal needs the other side to wave. Use the debug menu's
|
||||
<em>"Simulate a crowd"</em> to generate more silhouettes, or drive the mutual wave programmatically via the
|
||||
simulated adapter.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 6 — The reveal moment</h3>
|
||||
<p>When both sides wave, <code>RevealView</code> presents full-screen: two avatars converge. Tap
|
||||
<strong>"Say hello"</strong> to open the E2E-encrypted chat.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Step 7 — Chat</h3>
|
||||
<p>Type a message and send. In a two-simulator setup, the other side receives it decrypted in real time over
|
||||
the WebSocket.</p>
|
||||
</div>
|
||||
|
||||
<!-- ================= 8. TWO SIM ================= -->
|
||||
<h2 id="twosim"><span class="step-num">8</span> Two-simulator mutual reveal test</h2>
|
||||
<p>This tests the real-time mutual flow end-to-end across two "people".</p>
|
||||
|
||||
<div class="card">
|
||||
<ol>
|
||||
<li>Make sure the reference server is running (<a href="#server">Step 4</a>).</li>
|
||||
<li>In Xcode, boot a second simulator: <span class="kbd">File</span> → <span class="kbd">Open Simulator</span>,
|
||||
or use <span class="kbd">⌃⌘R</span> after adding a second scheme destination.</li>
|
||||
<li>Run the app on <strong>both</strong> simulators (e.g. iPhone 15 + iPhone 15 Pro).</li>
|
||||
<li>In each, sign in and go present.</li>
|
||||
<li>In each, use the debug menu to simulate an encounter (each gets a distinct token).</li>
|
||||
<li>Wave from one — the server derives each sender's own token from its session, records the directional edge,
|
||||
and when both directions exist it pushes <code>mutual</code> to <strong>both</strong> sockets.</li>
|
||||
<li>The reveal triggers on <strong>both</strong> devices in real time.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card tip">
|
||||
<strong>Tip:</strong> To run two instances easily, create a second scheme
|
||||
(<span class="kbd">Product</span> → <span class="kbd">Scheme</span> → <span class="kbd">New Scheme</span>) and
|
||||
set a different simulator destination, then run both schemes.
|
||||
</div>
|
||||
|
||||
<!-- ================= 9. UI TESTS ================= -->
|
||||
<h2 id="uitests"><span class="step-num">9</span> Run the automated UI tests</h2>
|
||||
<p>The <code>ProximityUITests</code> target drives the happy path automatically.</p>
|
||||
|
||||
<div class="card">
|
||||
<ol>
|
||||
<li>Ensure the server is running.</li>
|
||||
<li>In Xcode, select the <strong>ProximityUITests</strong> test target.</li>
|
||||
<li>Press <span class="kbd">⌘U</span> to run all tests.</li>
|
||||
</ol>
|
||||
<p>Or from the terminal:</p>
|
||||
<pre><code>cd ~/path/to/Nearfield_Friends
|
||||
xcodebuild test \
|
||||
-project Proximity.xcodeproj \
|
||||
-scheme Proximity \
|
||||
-destination 'platform=iOS Simulator,name=iPhone 15' \
|
||||
-only-testing:ProximityUITests/ProximityFlowUITests</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="card success">
|
||||
<strong>Ready to go:</strong> The <code>ProximityUITests</code> target and its shared scheme are already part of
|
||||
<code>Proximity.xcodeproj</code>, so <span class="kbd">⌘U</span> and the <code>xcodebuild test</code> command work
|
||||
out of the box.
|
||||
</div>
|
||||
|
||||
<!-- ================= 10. REAL DEVICE ================= -->
|
||||
<h2 id="reald"><span class="step-num">10</span> Testing on a real iPhone (the sensors)</h2>
|
||||
<p>When you're ready to test the actual UWB / BLE / NFC radios:</p>
|
||||
|
||||
<div class="card">
|
||||
<ol>
|
||||
<li><strong>Disable the simulated adapter.</strong> Build a Release (non-DEBUG) configuration, or set
|
||||
<code>AppState.isSimulated = false</code>.</li>
|
||||
<li>Use <strong>two physical iPhones</strong> (iPhone 11+ for UWB).</li>
|
||||
<li>Point the app at a reachable backend (change <code>APIClient.baseURL</code> to your Mac's LAN IP or a
|
||||
deployed server).</li>
|
||||
<li>Trust the developer certificate on each device (Settings → General → VPN & Device Management).</li>
|
||||
<li>Run from Xcode with the device selected. The real <code>UWBAdapter</code>, <code>BLEAdapter</code>,
|
||||
<code>BeaconAdapter</code> take over.</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="card danger">
|
||||
<strong>Important:</strong> UWB and NFC are <em>not</em> available in the simulator. Only test these on real
|
||||
hardware. Everything else works identically — that's the point of the abstraction.
|
||||
</div>
|
||||
|
||||
<!-- ================= 11. TROUBLESHOOT ================= -->
|
||||
<h2 id="troubleshoot"><span class="step-num">11</span> Troubleshooting on M-series</h2>
|
||||
|
||||
<div class="card warn">
|
||||
<h3 style="margin-top:0">App won't connect to the server</h3>
|
||||
<p><span class="check">✔</span> Confirm the server terminal shows <code>:8080</code> and is still running.<br>
|
||||
<span class="check">✔</span> Use the debug menu's <em>"Reconnect socket"</em>.<br>
|
||||
<span class="check">✔</span> Try <code>curl http://localhost:8080/v1/nearby?geohash=abc&tier=1</code> in Terminal.</p>
|
||||
</div>
|
||||
|
||||
<div class="card warn">
|
||||
<h3 style="margin-top:0">Build fails / missing files</h3>
|
||||
<p><span class="check">✔</span> Make sure all folders under <code>Proximity/</code> are added to the target
|
||||
(not just referenced).<br>
|
||||
<span class="check">✔</span> Confirm <code>ProximityApp.swift</code> is the <code>@main</code> entry and only one
|
||||
<code>@main</code> exists.</p>
|
||||
</div>
|
||||
|
||||
<div class="card warn">
|
||||
<h3 style="margin-top:0">Signing errors</h3>
|
||||
<p><span class="check">✔</span> For the simulator you can often use <em>"Sign to Run Locally"</em> / no team.<br>
|
||||
<span class="check">✔</span> Use a unique bundle identifier.</p>
|
||||
</div>
|
||||
|
||||
<div class="card warn">
|
||||
<h3 style="margin-top:0">"Rosetta" warnings or arch issues</h3>
|
||||
<p><span class="check">✔</span> This project is native arm64 — no Rosetta needed.<br>
|
||||
<span class="check">✔</span> If a tool was installed via an Intel package, reinstall via <code>brew</code>
|
||||
(arm64) or use the Apple Silicon build.</p>
|
||||
</div>
|
||||
|
||||
<div class="card warn">
|
||||
<h3 style="margin-top:0">Simulator can't find localhost</h3>
|
||||
<p><span class="check">✔</span> The simulator shares the Mac's network, so <code>localhost</code> resolves to
|
||||
your Mac. If you moved the server to another machine, update <code>APIClient.local()</code> to that IP.</p>
|
||||
</div>
|
||||
|
||||
<div class="card warn">
|
||||
<h3 style="margin-top:0">Mutual reveal never triggers in single-simulator mode</h3>
|
||||
<p><span class="check">✔</span> A reveal needs <em>both</em> sides to wave. Use the two-simulator setup
|
||||
(<a href="#twosim">Step 8</a>) for the full end-to-end test, or drive the mutual wave programmatically.</p>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
Proximity · Testing Guide · Apple Silicon (M-series) · iOS Simulator-first workflow
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
274
docs/backend-api-spec.md
Normal file
274
docs/backend-api-spec.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Proximity — Backend API Specification
|
||||
|
||||
This document defines the server contract that the **Swift iOS client** expects.
|
||||
Every endpoint and payload here mirrors exactly what's implemented in the client
|
||||
(`Services/Network/APIClient.swift` and `Services/Network/RealtimeClient.swift`).
|
||||
The goal is that a backend team can stand up this API from scratch and the
|
||||
existing client will work without changes.
|
||||
|
||||
**Base URL:** `https://api.proximity.app`
|
||||
|
||||
---
|
||||
|
||||
## 1. Design Principles
|
||||
|
||||
1. **Zero-knowledge by default.** The server correlates anonymous tokens and
|
||||
relays ciphertext. It never stores identity, plaintext messages, or precise
|
||||
location history.
|
||||
2. **Two identities, never conflated.**
|
||||
- *Account identity* — via auth (`/v1/auth/*`). Private, used for sessions.
|
||||
- *Social identity* — only surfaces in a **mutual** reveal, and even then
|
||||
the server only relays opaque keys; it never stores the profile.
|
||||
3. **Foreground-first.** The server is a correlation + relay layer, not a
|
||||
background continuous-tracking layer. Presence sessions are short-lived.
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication
|
||||
|
||||
### 2.1 `POST /v1/auth/exchange`
|
||||
Exchange a provider ID token for a Proximity session.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"provider": "apple", // "apple" | "google"
|
||||
"idToken": "<provider-jwt>",
|
||||
"nonce": "<nonce-or-empty>"
|
||||
}
|
||||
```
|
||||
|
||||
**Response `200` — `Session`:**
|
||||
```json
|
||||
{
|
||||
"token": "<proximity-session-bearer-token>",
|
||||
"account": {
|
||||
"id": "<opaque-account-id>",
|
||||
"providerID": "apple",
|
||||
"displayName": "…",
|
||||
"email": "…"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Server responsibilities:**
|
||||
- Verify the provider ID token with the provider (Apple/Google public keys).
|
||||
- For Apple, verify the `nonce` matches the one in the ID token.
|
||||
- Issue a short-lived Proximity session token (e.g. JWT, 7-day expiry).
|
||||
- Return only the fields the client's `Account` model decodes.
|
||||
|
||||
### 2.2 `POST /v1/auth/revoke`
|
||||
Revoke a session. Bearer token in `Authorization` header. No body expected.
|
||||
|
||||
---
|
||||
|
||||
## 3. Presence & Token Relay
|
||||
|
||||
> **Key model:** While present, a client broadcasts a **rotating anonymous
|
||||
> token** (see `TokenManager`). The server maps token → connected socket, so it
|
||||
> can push events to the right device without ever knowing the user's identity.
|
||||
|
||||
### 3.1 `POST /v1/token`
|
||||
Register the current anonymous token so the server can route to this device.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"token": "<rotating-anon-token>",
|
||||
"tier": 1, // DistanceTier.rawValue: 1=UWB, 2=BLE, 3=area
|
||||
"geohash": "…" // optional, for Tier 3 discovery
|
||||
}
|
||||
```
|
||||
|
||||
**Auth:** `Authorization: Bearer <session-token>`.
|
||||
|
||||
**Server:** associate `token` with the authenticated session and its open
|
||||
WebSocket. This is the **session → own-token** mapping that lets the server
|
||||
derive who a wave is *from*. Drop associations older than one rotation window
|
||||
(5 min).
|
||||
|
||||
### 3.2 `GET /v1/nearby?geohash=<h>&tier=<n>`
|
||||
Fetch anonymous tokens of present users near a coarse location.
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
["<token-a>", "<token-b>", "…"]
|
||||
```
|
||||
|
||||
**Server:** return tokens registered within the same coarse region for the
|
||||
requested tier. Used for Tier 3 (network) discovery. Tier 1/2 handshakes happen
|
||||
directly over UWB/BLE and are reported via `/v1/encounter`.
|
||||
|
||||
### 3.3 `POST /v1/encounter`
|
||||
Report a local physical encounter (fire-and-forget).
|
||||
|
||||
**Request body** is the client's `Encounter` model:
|
||||
```json
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"remoteAnonToken": "<token>",
|
||||
"timestamp": "…",
|
||||
"tier": 1,
|
||||
"engine": "uwb",
|
||||
"geohash": "…",
|
||||
"status": "silhouette"
|
||||
}
|
||||
```
|
||||
|
||||
**Server:** correlate the two tokens that were physically near each other and
|
||||
record the mutual relationship for later matching. **Do not** store identity or
|
||||
precise location.
|
||||
|
||||
---
|
||||
|
||||
## 4. The Reveal (Wave) Flow
|
||||
|
||||
This is the heart of the product. The server's job is to reliably relay a
|
||||
mutual wave between two present devices in real time.
|
||||
|
||||
### 4.1 `POST /v1/wave`
|
||||
Send a wave to a silhouette — signals interest in mutual reveal.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "token": "<remote-anon-token>" }
|
||||
```
|
||||
|
||||
**Auth:** `Authorization: Bearer <session-token>`.
|
||||
|
||||
**Server behavior:**
|
||||
1. Derive the sender's **own** token from the session (via `/v1/token`).
|
||||
2. Record a directional edge `ownToken → remoteToken`.
|
||||
3. Push a **`mutual`** event to the *other* device only if **both** users have
|
||||
waved at each other. (If the other user already waved, this device gets a
|
||||
`mutual` push back immediately.)
|
||||
4. On mutual, create/reuse a `connections` entry and include `connectionID`
|
||||
in the pushed event so messaging can route.
|
||||
5. A wave is **directional**: `A → B` does not reveal until `B → A`.
|
||||
|
||||
### 4.2 `POST /v1/peer-key` — upload own public key
|
||||
Upload the caller's E2E public key so a mutual peer can fetch it.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "publicKey": "<base64 raw X25519 public key>" }
|
||||
```
|
||||
|
||||
**Auth:** `Authorization: Bearer <session-token>`. The server stores the key
|
||||
against the caller's own token.
|
||||
|
||||
### 4.3 `GET /v1/peer-key?token=<remote-token>` — fetch peer key
|
||||
Fetch the peer's public key to establish the E2E session key.
|
||||
|
||||
**Auth:** `Authorization: Bearer <session-token>`.
|
||||
|
||||
**Response `200`:**
|
||||
```json
|
||||
{ "publicKey": "<base64 raw X25519 public key>" }
|
||||
```
|
||||
|
||||
**Server:** return the peer's key **only** if the caller and peer have a
|
||||
**mutual** wave relationship (`403` otherwise). This is the only
|
||||
identity-bearing data the server touches, and only within an established
|
||||
mutual relationship.
|
||||
|
||||
### 4.4 `POST /v1/block`
|
||||
Permanently block a token. Removes it from the graph in both directions.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{ "token": "<remote-anon-token>" }
|
||||
```
|
||||
|
||||
**Server:** delete the edge and refuse future waves/messages from this token.
|
||||
|
||||
---
|
||||
|
||||
## 5. Messaging (E2E)
|
||||
|
||||
### 5.1 `POST /v1/message`
|
||||
Send an encrypted message. **Only ciphertext leaves the device.**
|
||||
|
||||
**Request body** is the client's `ChatMessage` model:
|
||||
```json
|
||||
{
|
||||
"id": "<uuid>",
|
||||
"connectionID": "<uuid>",
|
||||
"senderID": "<opaque-user-id>",
|
||||
"ciphertext": "<base64 ChaChaPoly sealed box>",
|
||||
"sentAt": "…"
|
||||
}
|
||||
```
|
||||
|
||||
**Server:**
|
||||
- Validate the sender is part of the `connectionID`.
|
||||
- Store/relay the ciphertext only. **Never decrypt.**
|
||||
- Push a **`message`** event to the recipient's socket in real time.
|
||||
|
||||
---
|
||||
|
||||
## 6. Real-time WebSocket
|
||||
|
||||
**Endpoint:** `wss://api.proximity.app/ws`
|
||||
**Auth:** `Authorization: Bearer <proximity-session-token>`
|
||||
|
||||
The client connects when the user goes **present** and disconnects when they
|
||||
leave. The server must route events to the correct socket via the token
|
||||
registration from `/v1/token`.
|
||||
|
||||
### 6.1 Server → Client events
|
||||
|
||||
The client's `RealtimeClient` parses this envelope:
|
||||
```json
|
||||
{ "type": "mutual", "remoteToken": "<token>" }
|
||||
{ "type": "message", "message": { …ChatMessage… } }
|
||||
```
|
||||
|
||||
**`mutual`** — the other party waved back. The client matches `remoteToken`
|
||||
against its waiting encounter and advances to the reveal.
|
||||
|
||||
**`message`** — a new encrypted message. The client decrypts and appends.
|
||||
|
||||
### 6.2 Client → Server events (optional)
|
||||
The client may send `ping`/JSON keepalives; the server should respond with
|
||||
`{ "type": "pong" }`. The server must tolerate silent clients and drop stale
|
||||
socket→token mappings.
|
||||
|
||||
---
|
||||
|
||||
## 7. Data Model (server-side)
|
||||
|
||||
| Table | Fields | Notes |
|
||||
|---|---|---|
|
||||
| `accounts` | id, provider, display_name, email, created_at | account identity |
|
||||
| `sessions` | token, account_id, expires_at | bearer sessions |
|
||||
| `presence` | token, account_id, tier, geohash, socket_id, updated_at | rotating anon presence |
|
||||
| `encounters` | id, token_a, token_b, tier, engine, created_at | mutual proximity record |
|
||||
| `waves` | from_token, to_token, created_at | directional interest edges |
|
||||
| `keys` | token, public_key | E2E public keys (mutual only) |
|
||||
| `connections` | id, user_a, user_b, created_at | mutual reveals |
|
||||
| `messages` | id, connection_id, sender_id, ciphertext, created_at | ciphertext only |
|
||||
|
||||
---
|
||||
|
||||
## 8. Security & Privacy Checklist
|
||||
|
||||
- [ ] Provider tokens verified server-side (never trust client).
|
||||
- [ ] Session tokens short-lived + revocable.
|
||||
- [ ] Presence tokens rotate client-side; server drops stale ones.
|
||||
- [ ] `waves` are directional; no reveal without mutual consent.
|
||||
- [ ] `peer-key` returned only within a mutual relationship.
|
||||
- [ ] Messages stored as ciphertext only; no decryption server-side.
|
||||
- [ ] `block` is permanent and bidirectional.
|
||||
- [ ] Right-to-be-forgotten: deleting an account purges all rows.
|
||||
- [ ] No precise location history retained by default.
|
||||
|
||||
---
|
||||
|
||||
## 9. Suggested Tech Stack
|
||||
|
||||
- **Language:** Go or Node.js (TypeScript) — good WebSocket support.
|
||||
- **Transport:** HTTPS + WebSocket (single origin to share auth).
|
||||
- **Persistence:** PostgreSQL (encounters/waves/messages) + Redis (presence
|
||||
sockets, ephemeral token→socket mapping).
|
||||
- **Push:** APNs for offline wake-ups (best-effort; presence is foreground).
|
||||
Reference in New Issue
Block a user