initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project

This commit is contained in:
2026-08-12 00:22:01 +00:00
commit 9d1b0f8dd9
52 changed files with 6271 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
import Foundation
import Combine
/// Root application state. Owns the long-lived services and wires them together.
///
/// The central philosophy: **Presence is foreground-first.** The app is not a
/// passive background listener it is an active instrument you hold while you
/// are out in the world. This is a feature, not a limitation.
@MainActor
final class AppState: ObservableObject {
// MARK: - Published state
/// Whether the user is currently "open to connection" and the app is
/// actively broadcasting + listening. This is the master switch.
@Published var isPresent: Bool = false
/// The user's chosen radius tier. Defaults to "right here" (UWB).
@Published var radiusTier: DistanceTier = .rightHere
/// The current proximity engine (UWB / BLE / beacon) in use.
@Published private(set) var activeEngine: ProximityEngine = .uwb
// MARK: - Debug / simulation state
#if DEBUG
/// Whether we're using the simulated adapter (no hardware).
@Published var isSimulated = true
/// When true, the simulated adapter emits nearby users on a timer.
@Published var autoBroadcast = false {
didSet { configureSimulation() }
}
let simulatedAdapter = SimulatedAdapter()
#endif
// MARK: - Services
let presenceEngine: PresenceEngine
let tokenManager: TokenManager
let cryptoManager: CryptoManager
let apiClient: APIClient
let authService: AuthService
let profileImportService: ProfileImportService
let graphStore: GraphStore
let realtime: RealtimeClient
let revealFlow: RevealFlow
let chatViewModel: ChatViewModel
let presenceViewModel: PresenceViewModel
private var cancellables = Set<AnyCancellable>()
init() {
let tokenManager = TokenManager()
let cryptoManager = CryptoManager()
#if DEBUG
// In the simulator, point at the local reference server.
let apiClient = APIClient.local()
#else
let apiClient = APIClient()
#endif
self.tokenManager = tokenManager
self.cryptoManager = cryptoManager
self.apiClient = apiClient
let authService = AuthService(apiClient: apiClient)
self.authService = authService
let profileImportService = ProfileImportService()
self.profileImportService = profileImportService
let graphStore = GraphStore()
self.graphStore = graphStore
let realtime = RealtimeClient()
self.realtime = realtime
let revealFlow = RevealFlow(
apiClient: apiClient,
cryptoManager: cryptoManager,
profileService: profileImportService,
graphStore: graphStore,
realtime: realtime
)
self.revealFlow = revealFlow
let chatViewModel = ChatViewModel(
apiClient: apiClient,
cryptoManager: cryptoManager,
graphStore: graphStore,
realtime: realtime
)
self.chatViewModel = chatViewModel
let presenceEngine: PresenceEngine
#if DEBUG
// In DEBUG (simulator), drive the engine with the simulated adapter
// so the whole interaction loop can be tested without hardware.
presenceEngine = PresenceEngine(
tokenManager: tokenManager,
cryptoManager: cryptoManager,
apiClient: apiClient,
uwbAdapter: simulatedAdapter,
bleAdapter: simulatedAdapter,
beaconAdapter: simulatedAdapter
)
#else
presenceEngine = PresenceEngine(
tokenManager: tokenManager,
cryptoManager: cryptoManager,
apiClient: apiClient
)
#endif
self.presenceEngine = presenceEngine
self.presenceViewModel = PresenceViewModel(presenceEngine: presenceEngine)
// When the user toggles presence, drive the engine and open the
// live socket so mutual waves and messages arrive in real time.
$isPresent
.sink { [weak presenceEngine, weak realtime] present in
Task {
await presenceEngine?.setPresent(present)
if present {
realtime?.connect(token: tokenManager.currentToken())
} else {
realtime?.disconnect()
}
}
}
.store(in: &cancellables)
$radiusTier
.sink { [weak presenceEngine] tier in
Task { await presenceEngine?.setRadiusTier(tier) }
}
.store(in: &cancellables)
#if DEBUG
configureSimulation()
#endif
}
// MARK: - Debug / simulation
#if DEBUG
/// Forward the simulated adapter's events into the engine and set up
/// auto-broadcast when enabled.
private func configureSimulation() {
simulatedAdapter.autoBroadcast = autoBroadcast
// The engine already wired onEncounter/onBroadcast/onRegion to the
// simulated adapter at init. Auto-broadcast is handled internally.
}
/// Used by the debug menu to simulate a single nearby user.
func simulateEncounter() {
simulatedAdapter.simulateEncounter(remoteToken: "sim-\(Int.random(in: 100_000...999_999))")
}
/// Used by the debug menu to simulate a crowd.
func simulateCrowd() {
simulatedAdapter.simulateCrowd(count: 20)
}
/// Reconnect the realtime socket (e.g. after starting the local server).
func restartRealtime() {
realtime.connect(token: tokenManager.currentToken())
}
#endif
}

View File

@@ -0,0 +1,41 @@
import SwiftUI
@main
struct ProximityApp: App {
@StateObject private var appState = AppState()
var body: some Scene {
WindowGroup {
RootView()
.environmentObject(appState)
.environmentObject(appState.presenceViewModel)
.environmentObject(appState.authService)
.environmentObject(appState.profileImportService)
.environmentObject(appState.revealFlow)
.environmentObject(appState.chatViewModel)
.environmentObject(appState.realtime)
}
}
}
/// Routes between the sign-in flow and the main app based on auth state.
struct RootView: View {
@EnvironmentObject private var appState: AppState
@EnvironmentObject private var authService: AuthService
var body: some View {
Group {
switch authService.state {
case .signedOut:
SignInView()
case .authenticating:
ProgressView("Signing in…")
case .authenticated:
ContentView()
}
}
.task {
await authService.restoreSession()
}
}
}

View File

@@ -0,0 +1,26 @@
import Foundation
/// A single end-to-end encrypted message within a revealed connection.
struct ChatMessage: Identifiable, Codable, Hashable {
let id: UUID
let connectionID: UUID
let senderID: String
let ciphertext: Data
let sentAt: Date
var deliveredAt: Date?
var readAt: Date?
init(
id: UUID = UUID(),
connectionID: UUID,
senderID: String,
ciphertext: Data,
sentAt: Date = Date()
) {
self.id = id
self.connectionID = connectionID
self.senderID = senderID
self.ciphertext = ciphertext
self.sentAt = sentAt
}
}

View File

@@ -0,0 +1,35 @@
import Foundation
/// A fully revealed, mutually-consented connection between two present users.
///
/// Connections can **only** be created from a mutual `Encounter`. There is no
/// path to create one online you must have been physically near each other.
struct Connection: Identifiable, Codable, Hashable {
let id: UUID
let encounterID: UUID
let remoteUserID: String
let displayName: String
let createdAt: Date
let lastMessageAt: Date?
/// The E2E session key used for this conversation.
let sessionKeyID: String
init(
id: UUID = UUID(),
encounterID: UUID,
remoteUserID: String,
displayName: String,
createdAt: Date = Date(),
lastMessageAt: Date? = nil,
sessionKeyID: String
) {
self.id = id
self.encounterID = encounterID
self.remoteUserID = remoteUserID
self.displayName = displayName
self.createdAt = createdAt
self.lastMessageAt = lastMessageAt
self.sessionKeyID = sessionKeyID
}
}

View File

@@ -0,0 +1,49 @@
import Foundation
/// The physical range band a user is open to connecting within.
///
/// Each tier maps to a different sensing technology. The tiers are ordered so
/// higher tiers *include* lower ones (being open "in the area" also means
/// you're open "nearby" and "right here").
enum DistanceTier: Int, CaseIterable, Identifiable, Codable {
/// 09 m. Ultra-wideband (Nearby Interaction). "This exact person."
case rightHere = 1
/// 10100 m. Bluetooth Low Energy / iBeacon. "This room, this block."
case nearby = 2
/// 100 m50 mi. Server-relayed, opt-in coarse location. "This neighborhood."
case inTheArea = 3
var id: Int { rawValue }
var title: String {
switch self {
case .rightHere: return "Right Here"
case .nearby: return "Nearby"
case .inTheArea: return "In the Area"
}
}
var subtitle: String {
switch self {
case .rightHere: return "09 m · UWB"
case .nearby: return "10100 m · BLE"
case .inTheArea: return "100 m50 mi · Network"
}
}
/// The primary engine used for this tier.
var engine: ProximityEngine {
switch self {
case .rightHere: return .uwb
case .nearby: return .ble
case .inTheArea: return .beacon
}
}
/// Higher tiers include lower ones.
func includes(_ other: DistanceTier) -> Bool {
self.rawValue >= other.rawValue
}
}

View File

@@ -0,0 +1,55 @@
import Foundation
/// A real-world encounter between two present users.
///
/// An encounter is deliberately **anonymous** until both parties mutually
/// consent to reveal. Until then it is a "silhouette" a proof that two
/// humans were physically near each other, with no identity attached.
struct Encounter: Identifiable, Codable, Hashable {
let id: UUID
let remoteAnonToken: String
let timestamp: Date
let tier: DistanceTier
let engine: ProximityEngine
/// Coarse geohash (optional). Never precise location.
var geohash: String?
/// The relationship state of this encounter.
var status: EncounterStatus
/// A human-readable hint, e.g. "Cafe on 5th Ave" if the user tagged it.
var placeHint: String?
init(
id: UUID = UUID(),
remoteAnonToken: String,
timestamp: Date = Date(),
tier: DistanceTier,
engine: ProximityEngine,
geohash: String? = nil,
status: EncounterStatus = .silhouette,
placeHint: String? = nil
) {
self.id = id
self.remoteAnonToken = remoteAnonToken
self.timestamp = timestamp
self.tier = tier
self.engine = engine
self.geohash = geohash
self.status = status
self.placeHint = placeHint
}
}
/// Relationship lifecycle of an encounter.
enum EncounterStatus: String, Codable, Hashable {
/// Seen but not yet acknowledged. A silhouette.
case silhouette
/// This user has sent a "wave" (interest) but the other hasn't replied.
case waved
/// Both parties waved identities revealed, chat unlocked.
case mutual
/// One party declined or blocked. Permanently removed from the graph.
case declined
}

View File

@@ -0,0 +1,18 @@
import Foundation
/// The underlying sensing technology powering a connection tier.
enum ProximityEngine: String, CaseIterable, Codable {
case uwb // Nearby Interaction (U1/U2 chip)
case ble // Core Bluetooth
case beacon // Core Location (iBeacon region monitoring)
case nfc // Core NFC (tap-to-connect)
var displayName: String {
switch self {
case .uwb: return "Ultra-Wideband"
case .ble: return "Bluetooth"
case .beacon: return "Beacon"
case .nfc: return "NFC"
}
}
}

View File

@@ -0,0 +1,21 @@
import Foundation
/// The moment two present users mutually acknowledge each other.
///
/// This is the emotional climax of the product the instant two strangers
/// who were physically near each other both chose to be seen, and their
/// silhouettes resolve into people. Everything else in the app exists to
/// create this moment.
struct Reveal: Identifiable {
let id: UUID
/// The encounter that produced this reveal.
let encounter: Encounter
/// The connection created by mutual consent.
let connection: Connection
init(encounter: Encounter, connection: Connection) {
self.id = connection.id
self.encounter = encounter
self.connection = connection
}
}

View File

@@ -0,0 +1,33 @@
import Foundation
/// The user's own local identity and preferences.
///
/// Deliberately minimal: Proximity collects the *least* data needed to connect
/// humans. There is no profile photo, no bio, no social graph import.
struct UserProfile: Codable {
var id: UUID
var displayName: String
var publicKey: Data
/// Master "open to connection" state.
var isOpenToConnect: Bool
/// Preferred radius tier.
var radiusTier: DistanceTier
/// When true, the user is present but invisible to others.
var isIncognito: Bool
/// When true, the user is not discoverable at all.
var isBlocked: Bool
static let empty = UserProfile(
id: UUID(),
displayName: "",
publicKey: Data(),
isOpenToConnect: false,
radiusTier: .rightHere,
isIncognito: false,
isBlocked: false
)
}

View File

@@ -0,0 +1,128 @@
import Foundation
import AuthenticationServices
import CryptoKit
/// Apple "Sign in with Apple" the primary, privacy-respecting auth path.
///
/// Sign in with Apple is ideal for Proximity because it is designed around
/// privacy: users can hide their real email (relay), and we get a stable
/// opaque user identifier without a social graph. This keeps *account*
/// identity cleanly separated from any *revealed* social identity.
final class AppleAuthProvider: NSObject, AuthProvider, ASAuthorizationControllerDelegate {
var providerID: AuthProviderID { .apple }
private var continuation: CheckedContinuation<AuthResult, Error>?
private var currentNonce: String?
// MARK: - AuthProvider
func signIn() async throws -> AuthResult {
let nonce = randomNonceString()
currentNonce = nonce
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.fullName, .email]
request.nonce = sha256(nonce)
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.presentationContextProvider = self
controller.performRequests()
return try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
}
}
func signOut() async throws {
// Sign in with Apple has no server-side session to revoke here;
// our backend session is revoked separately.
}
// MARK: - ASAuthorizationControllerDelegate
func authorizationController(controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization) {
guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let idTokenData = credential.identityToken,
let idToken = String(data: idTokenData, encoding: .utf8) else {
continuation?.resume(throwing: AuthError.invalidCredential)
continuation = nil
return
}
let profile = ProviderProfile(
name: credential.fullName?.formatted(),
email: credential.email,
givenName: credential.fullName?.givenName,
familyName: credential.fullName?.familyName
)
continuation?.resume(returning: AuthResult(
providerID: .apple,
idToken: idToken,
nonce: currentNonce,
profile: profile
))
continuation = nil
}
func authorizationController(controller: ASAuthorizationController,
didCompleteWithError error: Error) {
continuation?.resume(throwing: error)
continuation = nil
}
// MARK: - Nonce helpers
private func randomNonceString(length: Int = 32) -> String {
precondition(length > 0)
let charset: [Character] =
Array("0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._")
var result = ""
var remaining = length
while remaining > 0 {
let randoms: [UInt8] = (0..<16).map { _ in
var r: UInt8 = 0
SecRandomCopyBytes(kSecRandomDefault, 1, &r)
return r
}
randoms.forEach { random in
if remaining == 0 { return }
if random < charset.count {
result.append(charset[Int(random)])
remaining -= 1
}
}
}
return result
}
private func sha256(_ input: String) -> String {
let hashed = SHA256.hash(data: Data(input.utf8))
return hashed.map { String(format: "%02x", $0) }.joined()
}
}
/// Errors thrown by the auth layer.
enum AuthError: Error, LocalizedError {
case invalidCredential
case providerUnavailable
var errorDescription: String? {
switch self {
case .invalidCredential: return "The sign-in credential was invalid."
case .providerUnavailable: return "This sign-in option is unavailable."
}
}
}
// MARK: - Presentation context provider
extension AppleAuthProvider: ASAuthorizationControllerPresentationContextProviding {
func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor {
// In production, return the active window scene.
ASPresentationAnchor()
}
}

View File

@@ -0,0 +1,46 @@
import Foundation
/// A provider that can authenticate a user and return an identity token
/// that our backend exchanges for a Proximity session.
protocol AuthProvider {
var providerID: AuthProviderID { get }
/// Begin the sign-in flow and return an ID token + raw profile claims.
func signIn() async throws -> AuthResult
/// Sign out locally.
func signOut() async throws
}
/// Supported authentication providers.
enum AuthProviderID: String, Codable, CaseIterable {
case apple
case google
// case x // X (Twitter) OAuth see note in AuthService
// case instagram // Instagram is NOT an OAuth provider for login
var displayName: String {
switch self {
case .apple: return "Apple"
case .google: return "Google"
}
}
}
/// The result of a successful provider sign-in.
struct AuthResult {
let providerID: AuthProviderID
/// Provider-issued ID token (JWT) sent to our backend for verification.
let idToken: String
/// Nonce used to prevent replay (Apple requires it).
let nonce: String?
/// Raw profile claims the provider returned (name, email, etc.).
let profile: ProviderProfile
}
/// Raw profile claims returned by a provider at sign-in time.
struct ProviderProfile {
var name: String?
var email: String?
var givenName: String?
var familyName: String?
var pictureURL: URL?
}

View File

@@ -0,0 +1,102 @@
import Foundation
import Combine
/// Orchestrates authentication and owns the authenticated session.
///
/// **Philosophy:** Auth establishes *account* identity (who you are to the
/// app) it never, by itself, reveals anything to other users. Social
/// identity is a separate, opt-in concern handled by `ProfileImportService`
/// at reveal time. This separation is what keeps the "anonymous until mutual
/// consent" promise intact.
@MainActor
final class AuthService: ObservableObject {
@Published private(set) var state: AuthState = .signedOut
@Published private(set) var account: Account?
private let providers: [AuthProviderID: AuthProvider]
private let apiClient: APIClient
private let keychain: KeychainStore
init(
apiClient: APIClient,
keychain: KeychainStore = KeychainStore(),
providers: [AuthProviderID: AuthProvider] = [
.apple: AppleAuthProvider(),
.google: GoogleAuthProvider()
]
) {
self.apiClient = apiClient
self.keychain = keychain
self.providers = providers
}
/// Restore a previously authenticated session from the keychain.
func restoreSession() async {
guard let session = keychain.loadSession() else { return }
state = .authenticated(session)
account = session.account
}
/// Sign in with a given provider.
func signIn(with providerID: AuthProviderID) async throws {
guard let provider = providers[providerID] else {
throw AuthError.providerUnavailable
}
state = .authenticating
do {
let result = try await provider.signIn()
// Exchange the provider ID token for a Proximity session token.
let session = try await apiClient.exchangeToken(result)
keychain.save(session)
state = .authenticated(session)
account = session.account
} catch {
state = .signedOut
throw error
}
}
/// Sign out locally and revoke the backend session.
func signOut() async {
if case let .authenticated(session) = state {
await apiClient.revokeSession(session)
}
keychain.clearSession()
state = .signedOut
account = nil
}
}
/// The user's authenticated account (account identity only).
struct Account: Codable, Hashable {
let id: String
let providerID: AuthProviderID
let displayName: String
let email: String?
}
/// The session state machine.
enum AuthState: Equatable {
case signedOut
case authenticating
case authenticated(Session)
static func == (lhs: AuthState, rhs: AuthState) -> Bool {
switch (lhs, rhs) {
case (.signedOut, .signedOut): return true
case (.authenticating, .authenticating): return true
case (.authenticated(let a), .authenticated(let b)): return a.token == b.token
default: return false
}
}
}
/// A verified Proximity session.
struct Session: Codable {
let token: String
let account: Account
}

View File

@@ -0,0 +1,73 @@
import Foundation
import AuthenticationServices
/// Google OAuth (via ASWebAuthenticationSession) an alternative account
/// identity path. Google is a true OAuth provider, unlike Instagram/X which
/// are not suitable for login (see AuthService notes).
final class GoogleAuthProvider: NSObject, AuthProvider {
var providerID: AuthProviderID { .google }
private let clientID = "YOUR_GOOGLE_CLIENT_ID.apps.googleusercontent.com"
private let redirectURI = "com.proximity.app:/oauth2redirect"
func signIn() async throws -> AuthResult {
// Build the Google OAuth2 authorization URL.
var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
components.queryItems = [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "scope", value: "openid email profile"),
URLQueryItem(name: "nonce", value: UUID().uuidString)
]
guard let url = components.url else {
throw AuthError.invalidCredential
}
// Present the ASWebAuthenticationSession and await the callback.
let callbackURL = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<URL, Error>) in
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: "com.proximity.app"
) { callback, error in
if let error {
continuation.resume(throwing: error)
} else if let callback {
continuation.resume(returning: callback)
} else {
continuation.resume(throwing: AuthError.invalidCredential)
}
}
session.presentationContextProvider = self
session.start()
}
// Extract the authorization code from the callback.
guard let components = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
let code = components.queryItems?.first(where: { $0.name == "code" })?.value else {
throw AuthError.invalidCredential
}
// In production: exchange `code` for an ID token at our backend,
// which verifies it with Google. Here we pass the code through.
return AuthResult(
providerID: .google,
idToken: code,
nonce: nil,
profile: ProviderProfile()
)
}
func signOut() async throws {
// Revoke handled by backend.
}
}
extension GoogleAuthProvider: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
ASPresentationAnchor()
}
}

View File

@@ -0,0 +1,111 @@
import Foundation
import AuthenticationServices
/// Imports a user's Instagram profile at reveal time.
///
/// **Important platform reality:** Instagram's Graph API does **not** expose a
/// general "get my profile" endpoint for arbitrary third-party apps the way
/// X does. Instagram is primarily a *login* provider (Instagram Login), and
/// profile data access is restricted. In practice, an Instagram import here
/// would either:
/// 1. Use Instagram Login to confirm identity + fetch basic profile fields
/// that Meta exposes to approved apps, or
/// 2. Rely on the user pasting their handle (verified by presence).
///
/// This provider scaffolds the OAuth flow and fetches what's available; the
/// exact fields depend on Meta's approval and API tier.
final class InstagramProfileProvider: NSObject, ProfileProvider {
var providerID: ProfileProviderID { .instagram }
private let clientID = "YOUR_INSTAGRAM_APP_ID"
private let redirectURI = "com.proximity.app:/instagram"
func fetchProfile() async throws -> ImportedProfile {
// 1. OAuth authorization via ASWebAuthenticationSession.
var components = URLComponents(string: "https://api.instagram.com/oauth/authorize")!
components.queryItems = [
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "scope", value: "user_profile"),
URLQueryItem(name: "response_type", value: "code")
]
guard let url = components.url else {
throw ProfileImportError.authorizationFailed
}
let callback = try await presentAuthSession(url: url)
guard let code = callback.queryItems?.first(where: { $0.name == "code" })?.value else {
throw ProfileImportError.authorizationFailed
}
// 2. Exchange the code for a short-lived token (production: via backend).
let token = try await exchangeCode(code)
// 3. Fetch the profile.
// Instagram's Graph API: GET /me?fields=username,full_name
var profileComponents = URLComponents(string: "https://graph.instagram.com/me")!
profileComponents.queryItems = [
URLQueryItem(name: "fields", value: "username,full_name"),
URLQueryItem(name: "access_token", value: token)
]
guard let profileURL = profileComponents.url else {
throw ProfileImportError.noProfile
}
struct IGResponse: Decodable {
let username: String?
let full_name: String?
}
let (data, _) = try await URLSession.shared.data(from: profileURL)
let response = try JSONDecoder().decode(IGResponse.self, from: data)
guard let username = response.username else {
throw ProfileImportError.noProfile
}
return ImportedProfile(
providerID: .instagram,
handle: username,
displayName: response.full_name
)
}
// MARK: - Helpers
private func presentAuthSession(url: URL) async throws -> URLComponents {
let callback = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<URL, Error>) in
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: "com.proximity.app"
) { callback, error in
if let error {
continuation.resume(throwing: error)
} else if let callback {
continuation.resume(returning: callback)
} else {
continuation.resume(throwing: ProfileImportError.authorizationFailed)
}
}
session.presentationContextProvider = self
session.start()
}
guard let components = URLComponents(url: callback, resolvingAgainstBaseURL: false) else {
throw ProfileImportError.authorizationFailed
}
return components
}
private func exchangeCode(_ code: String) async throws -> String {
// In production this must happen server-side to keep the app secret
// private. Returns a short-lived access token.
return "short-lived-token"
}
}
extension InstagramProfileProvider: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
ASPresentationAnchor()
}
}

View File

@@ -0,0 +1,47 @@
import Foundation
import Security
/// Secure persistence for the session token using the iOS Keychain.
/// Never stores the session in UserDefaults or plain files.
struct KeychainStore {
private let service = "com.proximity.app.session"
func save(_ session: Session) {
let data = (try? JSONEncoder().encode(session)) ?? Data()
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "session"
]
// Delete existing, then add.
SecItemDelete(query as CFDictionary)
var add = query
add[kSecValueData as String] = data
SecItemAdd(add as CFDictionary, nil)
}
func loadSession() -> Session? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "session",
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data else { return nil }
return try? JSONDecoder().decode(Session.self, from: data)
}
func clearSession() {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: "session"
]
SecItemDelete(query as CFDictionary)
}
}

View File

@@ -0,0 +1,116 @@
import Foundation
import Combine
/// Imports a user's **social profile** (Instagram, X) to enrich a revealed
/// connection.
///
/// **Critical privacy boundary:** This service is *never* used to build the
/// user's own discoverable account. It is only invoked at **reveal time**,
/// after a mutual encounter, when the user explicitly chooses to share a
/// richer identity with someone they've actually met.
///
/// Two separate identities exist in Proximity:
/// - **Account identity** (AuthService): who you are *to the app* private.
/// - **Social identity** (this service): what you *choose to reveal* opt-in.
///
/// Importing a profile is like handing someone your business card after
/// meeting them not broadcasting it to the world.
@MainActor
final class ProfileImportService: ObservableObject {
@Published private(set) var importedProfiles: [ImportedProfile] = []
private let providers: [ProfileProviderID: ProfileProvider]
init(providers: [ProfileProviderID: ProfileProvider] = [
.instagram: InstagramProfileProvider(),
.x: XProfileProvider()
]) {
self.providers = providers
}
/// Import a profile from a provider. Called only at reveal time.
func importProfile(from providerID: ProfileProviderID) async throws -> ImportedProfile {
guard let provider = providers[providerID] else {
throw ProfileImportError.providerUnavailable
}
let profile = try await provider.fetchProfile()
importedProfiles.append(profile)
return profile
}
/// Attach an imported profile to a revealed connection.
/// This is the *only* place a social identity can be linked to a person.
func attach(_ profile: ImportedProfile, to connectionID: UUID) {
// In production: persist the link locally, E2E-encrypted.
// The server never stores the raw social profile by default.
}
/// Remove an imported profile (user revokes a shared identity).
func remove(_ profile: ImportedProfile) {
importedProfiles.removeAll { $0.id == profile.id }
}
}
/// A social profile imported at reveal time.
struct ImportedProfile: Identifiable, Codable, Hashable {
let id: UUID
let providerID: ProfileProviderID
let handle: String
let displayName: String?
let bio: String?
let avatarURL: URL?
let importedAt: Date
init(
id: UUID = UUID(),
providerID: ProfileProviderID,
handle: String,
displayName: String? = nil,
bio: String? = nil,
avatarURL: URL? = nil,
importedAt: Date = Date()
) {
self.id = id
self.providerID = providerID
self.handle = handle
self.displayName = displayName
self.bio = bio
self.avatarURL = avatarURL
self.importedAt = importedAt
}
}
/// Providers that can supply a social profile.
enum ProfileProviderID: String, Codable, CaseIterable {
case instagram
case x
var displayName: String {
switch self {
case .instagram: return "Instagram"
case .x: return "X"
}
}
}
/// Errors from profile import.
enum ProfileImportError: Error, LocalizedError {
case providerUnavailable
case authorizationFailed
case noProfile
var errorDescription: String? {
switch self {
case .providerUnavailable: return "This profile provider is unavailable."
case .authorizationFailed: return "Authorization with the provider failed."
case .noProfile: return "No profile was returned."
}
}
}
/// A provider capable of fetching a user's own social profile.
protocol ProfileProvider {
var providerID: ProfileProviderID { get }
func fetchProfile() async throws -> ImportedProfile
}

View File

@@ -0,0 +1,141 @@
import Foundation
import AuthenticationServices
/// Imports a user's X (Twitter) profile at reveal time.
///
/// X's API (v2) exposes a clean `GET /2/users/me` endpoint that returns the
/// authenticated user's handle, name, and bio well-suited for enriching a
/// revealed connection. The OAuth 2.0 PKCE flow is used, and the token
/// exchange must happen server-side in production to protect the app secret.
final class XProfileProvider: NSObject, ProfileProvider {
var providerID: ProfileProviderID { .x }
private let clientID = "YOUR_X_CLIENT_ID"
private let redirectURI = "com.proximity.app:/x"
func fetchProfile() async throws -> ImportedProfile {
// 1. OAuth 2.0 PKCE authorization.
let verifier = generateCodeVerifier()
let challenge = generateCodeChallenge(verifier)
var components = URLComponents(string: "https://twitter.com/i/oauth2/authorize")!
components.queryItems = [
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "client_id", value: clientID),
URLQueryItem(name: "redirect_uri", value: redirectURI),
URLQueryItem(name: "scope", value: "users.read tweet.read"),
URLQueryItem(name: "state", value: UUID().uuidString),
URLQueryItem(name: "code_challenge", value: challenge),
URLQueryItem(name: "code_challenge_method", value: "S256")
]
guard let url = components.url else {
throw ProfileImportError.authorizationFailed
}
let callback = try await presentAuthSession(url: url)
guard let code = callback.queryItems?.first(where: { $0.name == "code" })?.value else {
throw ProfileImportError.authorizationFailed
}
// 2. Exchange code for access token (production: server-side).
let token = try await exchangeCode(code, verifier: verifier)
// 3. Fetch the authenticated user's profile.
var profileComponents = URLComponents(string: "https://api.twitter.com/2/users/me")!
profileComponents.queryItems = [
URLQueryItem(name: "user.fields", value: "name,username,description,profile_image_url")
]
var request = URLRequest(url: profileComponents.url!)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
struct XResponse: Decodable {
struct User: Decodable {
let username: String
let name: String?
let description: String?
let profile_image_url: String?
}
let data: User
}
let response = try JSONDecoder().decode(XResponse.self, from: data)
let user = response.data
return ImportedProfile(
providerID: .x,
handle: user.username,
displayName: user.name,
bio: user.description,
avatarURL: user.profile_image_url.flatMap(URL.init(string:))
)
}
// MARK: - Helpers
private func presentAuthSession(url: URL) async throws -> URLComponents {
let callback = try await withCheckedThrowingContinuation {
(continuation: CheckedContinuation<URL, Error>) in
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: "com.proximity.app"
) { callback, error in
if let error {
continuation.resume(throwing: error)
} else if let callback {
continuation.resume(returning: callback)
} else {
continuation.resume(throwing: ProfileImportError.authorizationFailed)
}
}
session.presentationContextProvider = self
session.start()
}
guard let components = URLComponents(url: callback, resolvingAgainstBaseURL: false) else {
throw ProfileImportError.authorizationFailed
}
return components
}
private func exchangeCode(_ code: String, verifier: String) async throws -> String {
// Production: POST to backend which exchanges code+verifier for a
// bearer token using the client secret. Returns short-lived token.
return "bearer-token"
}
// MARK: - PKCE
private func generateCodeVerifier() -> String {
var bytes = [UInt8](repeating: 0, count: 64)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return Data(bytes).base64URLEncodedString()
}
private func generateCodeChallenge(_ verifier: String) -> String {
let data = Data(verifier.utf8)
let digest = SHA256.hash(data: data)
return Data(digest).base64URLEncodedString()
}
}
extension XProfileProvider: ASWebAuthenticationPresentationContextProviding {
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
ASPresentationAnchor()
}
}
// MARK: - SHA256 + base64url helpers
import CryptoKit
extension Data {
/// Base64url (RFC 4648 §5) no padding, URL-safe alphabet.
func base64URLEncodedString() -> String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}

View File

@@ -0,0 +1,147 @@
import Foundation
import CryptoKit
/// Backend client for the Proximity relay.
///
/// The server's role is deliberately narrow and privacy-preserving:
/// - It **relays anonymous tokens** so two present devices can discover each
/// other (especially for UWB, which needs a peer token),
/// - It **correlates mutual encounters** (token A near token B) without ever
/// learning identities,
/// - It **delivers E2E-encrypted messages** (ciphertext only).
///
/// The server never stores precise location history or identity by default.
struct APIClient {
private let baseURL: URL
private let session = URLSession.shared
/// Point at the production API.
init() {
self.baseURL = URL(string: "https://api.proximity.app")!
}
/// Point at a custom backend (e.g. the local reference server).
init(baseURL: URL) {
self.baseURL = baseURL
}
// MARK: - Auth
/// Exchange a provider ID token for a Proximity session.
func exchangeToken(_ result: AuthResult) async throws -> Session {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/auth/exchange"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"provider": result.providerID.rawValue,
"idToken": result.idToken,
"nonce": result.nonce ?? ""
])
let (data, _) = try await session.data(for: request)
return try JSONDecoder().decode(Session.self, from: data)
}
/// Revoke a Proximity session.
func revokeSession(_ session: Session) async {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/auth/revoke"))
request.httpMethod = "POST"
request.setValue("Bearer \(session.token)", forHTTPHeaderField: "Authorization")
_ = try? await session.data(for: request)
}
// MARK: - Token relay
/// Register our current anonymous token so nearby peers can find us.
func registerToken(_ token: String, tier: DistanceTier) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/token"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"token": token,
"tier": tier.rawValue
])
_ = try await session.data(for: request)
}
/// Fetch anonymous tokens of present users near our coarse location.
func fetchNearbyTokens(geohash: String, tier: DistanceTier) async throws -> [String] {
var components = URLComponents(
url: baseURL.appendingPathComponent("v1/nearby"),
resolvingAgainstBaseURL: false
)!
components.queryItems = [
URLQueryItem(name: "geohash", value: geohash),
URLQueryItem(name: "tier", value: String(tier.rawValue))
]
let (data, _) = try await session.data(from: components.url!)
return try JSONDecoder().decode([String].self, from: data)
}
// MARK: - Encounters
/// Send a "wave" to a silhouette signals interest in mutual reveal.
func wave(to remoteToken: String) async {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/wave"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONEncoder().encode(["token": remoteToken])
_ = try? await session.data(for: request)
}
/// Block a token permanently removes it from the graph.
func block(token: String) async {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/block"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONEncoder().encode(["token": token])
_ = try? await session.data(for: request)
}
/// Report a local encounter so the server can correlate the mutual side.
func reportEncounter(_ encounter: Encounter) async {
// Fire-and-forget; failures are non-critical.
var request = URLRequest(url: baseURL.appendingPathComponent("v1/encounter"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONEncoder().encode(encounter)
_ = try? await session.data(for: request)
}
// MARK: - Reveal / key exchange
/// Upload our E2E public key so a mutual peer can fetch it.
func uploadPublicKey(_ key: Curve25519.KeyAgreement.PublicKey) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/peer-key"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode([
"publicKey": key.rawRepresentation.base64EncodedString()
])
_ = try await session.data(for: request)
}
/// Fetch the peer's public key for a mutual encounter, via the relay.
func fetchPeerPublicKey(for remoteToken: String) async throws -> Curve25519.KeyAgreement.PublicKey {
var components = URLComponents(
url: baseURL.appendingPathComponent("v1/peer-key"),
resolvingAgainstBaseURL: false
)!
components.queryItems = [URLQueryItem(name: "token", value: remoteToken)]
let (data, _) = try await session.data(from: components.url!)
struct KeyResponse: Decodable { let publicKey: Data }
let response = try JSONDecoder().decode(KeyResponse.self, from: data)
return try Curve25519.KeyAgreement.PublicKey(rawRepresentation: response.publicKey)
}
// MARK: - Messaging (E2E)
/// Send an encrypted message. Only ciphertext leaves the device.
func sendMessage(_ message: ChatMessage) async throws {
var request = URLRequest(url: baseURL.appendingPathComponent("v1/message"))
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(message)
_ = try await session.data(for: request)
}
}

View File

@@ -0,0 +1,44 @@
import Foundation
import SwiftData
/// Local persistence for the encounter graph using SwiftData (SQLite-backed).
///
/// The graph lives **on-device** by default. The user is in control of what
/// syncs to the server. Encounters are stored as anonymous silhouettes until
/// mutual consent promotes them to revealed connections.
@Model
final class GraphStore {
var encounters: [Encounter] = []
var connections: [Connection] = []
var messages: [ChatMessage] = []
init() {}
// MARK: - Encounters
func addEncounter(_ encounter: Encounter) {
encounters.insert(encounter, at: 0)
}
func encounter(byToken token: String) -> Encounter? {
encounters.first { $0.remoteAnonToken == token }
}
func updateStatus(_ status: EncounterStatus, for token: String) {
guard let idx = encounters.firstIndex(where: { $0.remoteAnonToken == token }) else { return }
encounters[idx].status = status
}
// MARK: - Connections
func addConnection(_ connection: Connection) {
connections.insert(connection, at: 0)
}
// MARK: - Messages
func addMessage(_ message: ChatMessage) {
messages.append(message)
}
}

View File

@@ -0,0 +1,132 @@
import Foundation
import Combine
/// WebSocket client for real-time encounter correlation and messaging.
///
/// This is the "live wire" of the interaction moment. When two present users
/// are near each other, the server pushes a `mutual` event so both clients
/// can trigger the reveal in real time. Revealed messages also arrive here
/// (E2E ciphertext only the server never sees plaintext).
///
/// The client is a shared, observable singleton-style service so that both
/// `RevealFlow` (mutual waves) and `ChatViewModel` (messages) consume the
/// same socket. It auto-reconnects with backoff.
@MainActor
final class RealtimeClient: ObservableObject {
// MARK: - Published state
@Published private(set) var isConnected = false
// MARK: - Events (Combine publishers)
/// Emits a remote anonymous token when the server reports a mutual wave.
let mutualEncounter = PassthroughSubject<String, Never>()
/// Emits an incoming encrypted message.
let incomingMessage = PassthroughSubject<ChatMessage, Never>()
// MARK: - Private
private let socketURL = URL(string: "wss://api.proximity.app/ws")!
private var task: URLSessionWebSocketTask?
private var authToken: String?
private var reconnectAttempts = 0
private var reconnectTask: Task<Void, Never>?
private var isActive = false
// MARK: - Lifecycle
/// Connect using the current session token. Idempotent.
func connect(token: String) {
authToken = token
isActive = true
reconnectAttempts = 0
openSocket()
}
func disconnect() {
isActive = false
reconnectTask?.cancel()
task?.cancel(with: .goingAway, reason: nil)
task = nil
isConnected = false
}
private func openSocket() {
guard isActive else { return }
task?.cancel()
var request = URLRequest(url: socketURL)
if let authToken {
request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
}
let session = URLSession(configuration: .default)
let task = session.webSocketTask(with: request)
self.task = task
task.resume()
receiveLoop()
}
// MARK: - Receiving
private func receiveLoop() {
task?.receive { [weak self] result in
guard let self else { return }
switch result {
case .success(let message):
self.isConnected = true
self.reconnectAttempts = 0
switch message {
case .data(let data):
self.handle(data)
case .string(let string):
self.handle(Data(string.utf8))
@unknown default:
break
}
self.receiveLoop()
case .failure:
self.handleDisconnect()
}
}
}
private func handleDisconnect() {
isConnected = false
guard isActive else { return }
// Exponential backoff reconnect.
let delay = min(pow(2.0, Double(reconnectAttempts)), 30.0)
reconnectAttempts += 1
reconnectTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
guard !Task.isCancelled else { return }
self?.openSocket()
}
}
// MARK: - Event parsing
private func handle(_ data: Data) {
struct Event: Decodable {
let type: String
let remoteToken: String?
let message: ChatMessage?
}
guard let event = try? JSONDecoder().decode(Event.self, from: data) else { return }
switch event.type {
case "mutual":
if let token = event.remoteToken {
mutualEncounter.send(token)
}
case "message":
if let message = event.message {
incomingMessage.send(message)
}
default:
break
}
}
}

View File

@@ -0,0 +1,103 @@
import Foundation
import CoreBluetooth
import Combine
/// Wraps **Core Bluetooth** for Tier 2 ("Nearby") presence.
///
/// BLE lets us broadcast our anonymous token and discover nearby present users
/// within ~10100 m. We advertise an **empty local name** and a rotating
/// service UUID so no identity leaks over the air.
///
/// Note on background: iOS severely limits background BLE. This is fine the
/// product is foreground-first by design. When the app is open and present,
/// BLE works reliably.
final class BLEAdapter: NSObject, CBCentralManagerDelegate, CBPeripheralManagerDelegate, BroadcastingAdapter {
var onBroadcast: ((_ remoteToken: String) -> Void)?
private var centralManager: CBCentralManager!
private var peripheralManager: CBPeripheralManager!
private var serviceUUID: CBUUID?
private var myToken: String = ""
private var discovered = Set<String>()
private let queue = DispatchQueue(label: "proximity.ble")
// MARK: - Lifecycle
func start(token: String) async {
myToken = token
discovered.removeAll()
// Rotating service UUID derived from the current token window.
serviceUUID = CBUUID(string: uuidString(from: token))
centralManager = CBCentralManager(delegate: self, queue: queue)
peripheralManager = CBPeripheralManager(delegate: self, queue: queue)
}
func stop() async {
centralManager?.stopScan()
peripheralManager?.stopAdvertising()
centralManager = nil
peripheralManager = nil
}
// MARK: - Advertising (peripheral side)
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
guard peripheral.state == .poweredOn,
let serviceUUID else { return }
let service = CBMutableService(type: serviceUUID, primary: true)
peripheral.add(service)
peripheral.startAdvertising([
CBAdvertisementDataServiceUUIDsKey: [serviceUUID],
CBAdvertisementDataLocalNameKey: "" // anonymous no name broadcast
])
}
// MARK: - Scanning (central side)
func centralManagerDidUpdateState(_ central: CBCentralManager) {
guard central.state == .poweredOn,
let serviceUUID else { return }
central.scanForPeripherals(withServices: [serviceUUID], options: nil)
}
func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber) {
// Extract our token from the advertised service data.
guard let data = advertisementData[CBAdvertisementDataServiceDataKey] as? [CBUUID: Data],
let tokenData = data[serviceUUID ?? CBUUID()],
let token = String(data: tokenData, encoding: .utf8),
token != myToken,
!discovered.contains(token) else { return }
discovered.insert(token)
onBroadcast?(token)
}
// MARK: - Helpers
/// Derive a stable 128-bit UUID from a token for the rotating service ID.
private func uuidString(from token: String) -> String {
// In production: HMAC the token into a UUID-shaped string.
// Simplified here for clarity.
var digest = [UInt8](repeating: 0, count: 16)
let bytes = Array(token.utf8)
for (i, b) in bytes.enumerated() {
digest[i % 16] ^= b
}
digest[6] = (digest[6] & 0x0F) | 0x40 // version 4
digest[8] = (digest[8] & 0x3F) | 0x80 // variant
return UUID(uuid: (digest[0], digest[1], digest[2], digest[3],
digest[4], digest[5], digest[6], digest[7],
digest[8], digest[9], digest[10], digest[11],
digest[12], digest[13], digest[14], digest[15])).uuidString
}
}

View File

@@ -0,0 +1,77 @@
import Foundation
import CoreLocation
import Combine
/// Wraps **Core Location iBeacon region monitoring** for Tier 3 ("In the Area")
/// and as the *only* mechanism that can wake the app from the background.
///
/// iBeacon region monitoring is the one proximity primitive iOS reliably runs
/// in the background. We use it to detect "a Proximity beacon is nearby" and
/// wake the app so the foreground engines (UWB/BLE) can take over for the
/// actual handshake. This is a deliberate division of labor:
///
/// Beacon = "someone is out here" (background wake)
/// UWB/BLE = "let's actually connect" (foreground handshake)
final class BeaconAdapter: NSObject, CLLocationManagerDelegate, RegionAdapter {
var onRegion: ((_ remoteToken: String) -> Void)?
private let locationManager = CLLocationManager()
// A shared, well-known Proximity beacon UUID (in production, per-region).
private let beaconUUID = UUID(uuidString: "E621E1F8-C36C-495A-93FC-0C247A3E6E5F")!
private var monitoredRegions = Set<String>()
override init() {
super.init()
locationManager.delegate = self
}
// MARK: - Lifecycle
func start(token: String) async {
let status = locationManager.authorizationStatus
guard status == .authorizedAlways || status == .authorizedWhenInUse else {
// Request "when in use" for foreground presence.
locationManager.requestWhenInUseAuthorization()
return
}
// Monitor the shared Proximity region so we get background wake-ups.
let region = CLBeaconRegion(uuid: beaconUUID, identifier: "proximity.region")
locationManager.startMonitoring(for: region)
monitoredRegions.insert(region.identifier)
}
func stop() async {
for identifier in monitoredRegions {
locationManager.stopMonitoring(for: CLBeaconRegion(
uuid: beaconUUID,
identifier: identifier
))
}
monitoredRegions.removeAll()
}
// MARK: - CLLocationManagerDelegate
func locationManager(_ manager: CLLocationManager,
didDetermineState state: CLRegionState,
for region: CLRegion) {
if state == .inside {
// A Proximity beacon is nearby. Wake the foreground engines to
// perform the actual handshake.
onRegion?("beacon-region")
}
}
func locationManager(_ manager: CLLocationManager,
didEnter region: CLRegion) {
onRegion?("beacon-region")
}
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
// Re-evaluate on authorization change.
}
}

View File

@@ -0,0 +1,172 @@
import Foundation
import Combine
/// The central orchestrator for presence.
///
/// **Philosophy:** Presence is an active, foreground act. The engine only runs
/// while the user is present (app open + "Open to Connect" on). It is not a
/// passive background listener it is an instrument you hold while you are
/// out in the world. This foreground-first design is what makes the product
/// meaningful: you must *show up* to connect.
///
/// The engine composes the hardware adapters (UWB, BLE, Beacon) and translates
/// raw proximity signals into anonymous `Encounter`s.
@MainActor
final class PresenceEngine: ObservableObject {
// MARK: - Published state
@Published private(set) var isPresent: Bool = false
@Published private(set) var radiusTier: DistanceTier = .rightHere
@Published private(set) var activeEngines: Set<ProximityEngine> = []
@Published private(set) var recentEncounters: [Encounter] = []
@Published private(set) var lastScan: Date?
// MARK: - Dependencies
private let tokenManager: TokenManager
private let cryptoManager: CryptoManager
private let apiClient: APIClient
// Hardware adapters (injected for testability).
private let uwbAdapter: HandshakeAdapter
private let bleAdapter: BroadcastingAdapter
private let beaconAdapter: RegionAdapter
// MARK: - Init
init(
tokenManager: TokenManager,
cryptoManager: CryptoManager,
apiClient: APIClient,
uwbAdapter: HandshakeAdapter = UWBAdapter(),
bleAdapter: BroadcastingAdapter = BLEAdapter(),
beaconAdapter: RegionAdapter = BeaconAdapter()
) {
self.tokenManager = tokenManager
self.cryptoManager = cryptoManager
self.apiClient = apiClient
self.uwbAdapter = uwbAdapter
self.bleAdapter = bleAdapter
self.beaconAdapter = beaconAdapter
// Forward adapter events into our published state.
uwbAdapter.onHandshake = { [weak self] token, distance in
Task { await self?.handleUWBEncounter(token: token, distance: distance) }
}
bleAdapter.onBroadcast = { [weak self] token in
Task { await self?.handleBLEEncounter(token: token) }
}
beaconAdapter.onRegion = { [weak self] token in
Task { await self?.handleBeaconEncounter(token: token) }
}
}
// MARK: - Public API
/// Turn presence on or off. This is the master switch.
func setPresent(_ present: Bool) async {
isPresent = present
if present {
await startEngines()
} else {
await stopEngines()
}
}
func setRadiusTier(_ tier: DistanceTier) async {
radiusTier = tier
// Reconfigure which engines are active for the new tier.
if isPresent {
await startEngines()
}
}
// MARK: - Engine lifecycle
private func startEngines() async {
let token = tokenManager.currentToken()
// Register our rotating anon token with the backend so the server
// can route socket events (mutual waves, messages) to this device.
try? await apiClient.registerToken(token, tier: radiusTier)
// Determine which engines the current tier needs.
var desired: Set<ProximityEngine> = []
if radiusTier.includes(.rightHere) { desired.insert(.uwb) }
if radiusTier.includes(.nearby) { desired.insert(.ble) }
if radiusTier.includes(.inTheArea) { desired.insert(.beacon) }
activeEngines = desired
if desired.contains(.uwb) {
await uwbAdapter.start(token: token)
}
if desired.contains(.ble) {
await bleAdapter.start(token: token)
}
if desired.contains(.beacon) {
await beaconAdapter.start(token: token)
}
lastScan = Date()
}
private func stopEngines() async {
await uwbAdapter.stop()
await bleAdapter.stop()
await beaconAdapter.stop()
activeEngines = []
}
// MARK: - Encounter handling
private func handleUWBEncounter(token: String, distance: Float) async {
// UWB gives us precise distance; only count a real handshake when close.
guard distance <= 2.0 else { return }
await recordEncounter(token: token, tier: .rightHere, engine: .uwb)
}
private func handleBLEEncounter(token: String) async {
await recordEncounter(token: token, tier: .nearby, engine: .ble)
}
private func handleBeaconEncounter(token: String) async {
await recordEncounter(token: token, tier: .inTheArea, engine: .beacon)
}
// MARK: - User actions on encounters
/// Send a "wave" to a silhouette signals interest in mutual reveal.
func wave(to encounter: Encounter) async {
guard let idx = recentEncounters.firstIndex(where: { $0.id == encounter.id }) else { return }
recentEncounters[idx].status = .waved
await apiClient.wave(to: encounter.remoteAnonToken)
}
/// Block an encounter permanently removes it from the graph.
func block(_ encounter: Encounter) async {
recentEncounters.removeAll { $0.id == encounter.id }
await apiClient.block(token: encounter.remoteAnonToken)
}
private func recordEncounter(token: String, tier: DistanceTier, engine: ProximityEngine) async {
// Ignore our own token or invalid (expired) tokens.
guard token != tokenManager.currentToken(),
tokenManager.isValid(token) else { return }
// Deduplicate: ignore if we already recorded this token recently.
let recent = recentEncounters.contains { $0.remoteAnonToken == token }
guard !recent else { return }
let encounter = Encounter(
remoteAnonToken: token,
tier: tier,
engine: engine
)
recentEncounters.insert(encounter, at: 0)
// Notify the backend so it can correlate the mutual encounter.
await apiClient.reportEncounter(encounter)
}
}

View File

@@ -0,0 +1,42 @@
import Foundation
// MARK: - Adapter protocols
//
// These protocols are the seam between the *interaction logic* and the
// *hardware*. They exist so the app can be fully exercised in the iOS
// Simulator (and in previews/UITests) with a simulated adapter, while real
// iPhones use the real UWB / BLE / Beacon / NFC adapters.
//
// This is the key to testing without a device: `PresenceEngine` talks to
// these protocols, not to the hardware directly.
//
// Each protocol has a *distinct* event property (`onHandshake`, `onBroadcast`,
// `onRegion`) so that a single type (like `SimulatedAdapter`) can conform to
// all three at once without name collisions.
/// Drives a precise handshake with a specific peer (UWB or simulated).
protocol HandshakeAdapter: AnyObject {
/// Called when a peer comes within handshake range: (token, distance in m).
var onHandshake: ((_ remoteToken: String, _ distance: Float) -> Void)? { get set }
func start(token: String) async
func stop() async
}
/// Broadcasts and discovers presence over a local medium (BLE or simulated).
protocol BroadcastingAdapter: AnyObject {
/// Called when a nearby present user is discovered.
var onBroadcast: ((_ remoteToken: String) -> Void)? { get set }
func start(token: String) async
func stop() async
}
/// Detects region presence (beacon or simulated).
protocol RegionAdapter: AnyObject {
/// Called when a configured region becomes present.
var onRegion: ((_ remoteToken: String) -> Void)? { get set }
func start(token: String) async
func stop() async
}

View File

@@ -0,0 +1,211 @@
import Foundation
import Combine
import CryptoKit
/// The state machine governing a single interaction with another present user.
///
/// This is the heart of the product. It drives the emotional arc of a
/// physical encounter from anonymous silhouette to mutually-revealed person.
///
/// ```
/// silhouette wave waved (other waves) mutual revealed
///
/// block gone block gone
/// ```
///
/// A reveal is **only** possible through physical proximity + mutual consent.
/// There is no path to skip ahead you cannot reach `revealed` without
/// having been near each other and both choosing to be seen.
@MainActor
final class RevealFlow: ObservableObject {
// MARK: - Published state
@Published private(set) var phase: Phase = .idle
@Published private(set) var currentEncounter: Encounter?
@Published private(set) var revealedConnection: Connection?
// MARK: - Dependencies
private let apiClient: APIClient
private let cryptoManager: CryptoManager
private let profileService: ProfileImportService
private let graphStore: GraphStore
private let realtime: RealtimeClient
private var myPrivateKey: Curve25519.KeyAgreement.PrivateKey?
private var theirPublicKey: Curve25519.KeyAgreement.PublicKey?
private var cancellables = Set<AnyCancellable>()
init(
apiClient: APIClient,
cryptoManager: CryptoManager,
profileService: ProfileImportService,
graphStore: GraphStore,
realtime: RealtimeClient
) {
self.apiClient = apiClient
self.cryptoManager = cryptoManager
self.profileService = profileService
self.graphStore = graphStore
self.realtime = realtime
// Live wire: when the server reports the other party waved back,
// advance the interaction to the reveal moment in real time.
realtime.mutualEncounter
.sink { [weak self] remoteToken in
Task { await self?.handleIncomingMutualWave(token: remoteToken) }
}
.store(in: &cancellables)
}
// MARK: - Phases
enum Phase: Equatable {
/// No active interaction.
case idle
/// We've seen a silhouette and are deciding whether to wave.
case considering(Encounter)
/// We waved; waiting for the other person to respond.
case waiting(Encounter)
/// Both waved this is the reveal moment.
case mutual(Encounter)
/// Identities exchanged; conversation unlocked.
case revealed(Connection)
/// One party declined or blocked.
case declined
}
// MARK: - Actions
/// Open a silhouette to consider it.
func consider(_ encounter: Encounter) {
phase = .considering(encounter)
currentEncounter = encounter
}
/// Send a wave "I see you, and I'd like to be seen."
func wave() async {
guard case let .considering(encounter) = phase else { return }
phase = .waiting(encounter)
// Generate and upload our E2E public key so the peer can fetch it
// once the reveal becomes mutual.
let (myPrivate, myPublic) = cryptoManager.generateKeyPair()
myPrivateKey = myPrivate
try? await apiClient.uploadPublicKey(myPublic)
// Notify the backend so the other party's app can present the reveal.
await apiClient.wave(to: encounter.remoteAnonToken)
}
/// Called from the realtime socket when the server reports the other
/// party waved back. Maps the remote token to our waiting encounter and
/// advances to the reveal moment.
private func handleIncomingMutualWave(token: String) async {
// Only react if we're currently waiting on this exact encounter.
guard case let .waiting(encounter) = phase,
encounter.remoteAnonToken == token else { return }
await handleMutualWave(for: encounter)
}
/// The other party waved back. This is the reveal moment.
func handleMutualWave(for encounter: Encounter) async {
// Only proceed if we were already waiting on this encounter.
guard case .waiting = phase, currentEncounter?.id == encounter.id else { return }
do {
// Establish the E2E session key for the conversation. Reuse the
// key pair generated at wave time if we have one.
let myPrivate: Curve25519.KeyAgreement.PrivateKey
if let existing = myPrivateKey {
myPrivate = existing
} else {
let (generated, myPublic) = cryptoManager.generateKeyPair()
myPrivate = generated
try? await apiClient.uploadPublicKey(myPublic)
}
myPrivateKey = myPrivate
// Exchange public keys with the peer via the backend relay.
let theirPublic = try await apiClient.fetchPeerPublicKey(
for: encounter.remoteAnonToken
)
theirPublicKey = theirPublic
let sessionKey = try cryptoManager.deriveSessionKey(
myPrivate: myPrivate,
theirPublic: theirPublic
)
// Promote the encounter to a mutual connection.
let connection = Connection(
encounterID: encounter.id,
remoteUserID: encounter.remoteAnonToken,
displayName: "New connection", // revealed name comes via profile
sessionKeyID: sessionKeyID(sessionKey)
)
graphStore.updateStatus(.mutual, for: encounter.remoteAnonToken)
graphStore.addConnection(connection)
phase = .mutual(encounter)
revealedConnection = connection
} catch {
// If key exchange fails, fall back to a graceful reveal without E2E.
let connection = Connection(
encounterID: encounter.id,
remoteUserID: encounter.remoteAnonToken,
displayName: "New connection",
sessionKeyID: ""
)
graphStore.updateStatus(.mutual, for: encounter.remoteAnonToken)
graphStore.addConnection(connection)
phase = .mutual(encounter)
revealedConnection = connection
}
}
/// The user chooses to proceed from the reveal moment into conversation.
func proceedToConversation() {
guard case let .mutual(encounter) = phase,
let connection = revealedConnection else { return }
phase = .revealed(connection)
currentEncounter = encounter
}
/// Decline or block permanently ends this interaction.
func decline() async {
if let encounter = currentEncounter {
graphStore.updateStatus(.declined, for: encounter.remoteAnonToken)
await apiClient.block(token: encounter.remoteAnonToken)
}
reset()
phase = .declined
}
/// Return to idle (e.g. user dismisses the reveal sheet).
func reset() {
phase = .idle
currentEncounter = nil
revealedConnection = nil
myPrivateKey = nil
theirPublicKey = nil
}
// MARK: - Helpers
/// A stable identifier for a session key (for storage, not secrecy).
private func sessionKeyID(_ key: SymmetricKey) -> String {
key.withUnsafeBytes { Data($0).base64EncodedString() }
}
/// The E2E session key for the active conversation, if established.
func currentSessionKey() -> SymmetricKey? {
guard let myPrivate = myPrivateKey,
let theirPublic = theirPublicKey else { return nil }
return try? cryptoManager.deriveSessionKey(
myPrivate: myPrivate,
theirPublic: theirPublic
)
}
}

View File

@@ -0,0 +1,110 @@
import Foundation
/// A simulated proximity adapter for testing without physical hardware.
///
/// This is the heart of the "test without an iPhone" strategy. It conforms to
/// the same protocols as the real hardware adapters, so `PresenceEngine` works
/// identically it just lets you *inject* encounters instead of sensing them.
///
/// You can drive it two ways:
/// 1. **Programmatically** from a debug menu or a UITest, call
/// `simulateEncounter(...)` to pretend a user walked by.
/// 2. **Automatically** set `autoBroadcast` to have it emit nearby tokens
/// on a timer, simulating a busy street.
///
/// Because it's a drop-in replacement, the entire interaction loop (silhouette
/// wave reveal chat) can be exercised in the simulator with no hardware.
@MainActor
final class SimulatedAdapter: HandshakeAdapter, BroadcastingAdapter, RegionAdapter {
// MARK: - Protocol conformance
var onHandshake: ((_ remoteToken: String, _ distance: Float) -> Void)?
var onBroadcast: ((_ remoteToken: String) -> Void)?
var onRegion: ((_ remoteToken: String) -> Void)?
// MARK: - Configuration
var isRunning = false
var autoBroadcast = false
var broadcastInterval: TimeInterval = 3.0
var myToken = ""
private var autoTask: Task<Void, Never>?
// MARK: - Start / stop (all three protocols)
func start(token: String) async {
myToken = token
isRunning = true
if autoBroadcast {
startAutoBroadcast()
}
}
func stop() async {
isRunning = false
autoTask?.cancel()
autoTask = nil
}
// MARK: - Simulation API
/// Pretend a peer walked within UWB range (Tier 1).
func simulateEncounter(remoteToken: String, distance: Float = 1.0) {
guard isRunning else { return }
onHandshake?(remoteToken, distance)
}
/// Pretend a nearby BLE user was discovered (Tier 2).
func simulateBroadcast(remoteToken: String) {
guard isRunning else { return }
onBroadcast?(remoteToken)
}
/// Pretend a beacon region became present (Tier 3 / background wake).
func simulateRegion(remoteToken: String) {
guard isRunning else { return }
onRegion?(remoteToken)
}
/// Emit a burst of encounters, like walking through a crowd.
func simulateCrowd(count: Int) {
guard isRunning else { return }
for i in 0..<count {
let token = "sim-\(Int.random(in: 100_000...999_999))-\(i)"
onHandshake?(token, Float.random(in: 0.5...8.0))
}
}
// MARK: - Auto broadcast
private func startAutoBroadcast() {
autoTask = Task { [weak self] in
while !Task.isCancelled {
guard let self, self.isRunning else { return }
try? await Task.sleep(nanoseconds:
UInt64(self.broadcastInterval * 1_000_000_000))
self.onHandshake?(
"sim-auto-\(Int.random(in: 100_000...999_999))",
Float.random(in: 0.5...8.0)
)
}
}
}
}
// MARK: - APIClient pointing at the simulator backend
/// A convenience: the localhost API client so the simulator talks to the
/// reference server running on the same machine.
extension APIClient {
/// Initializer for local development against the reference server.
static func local() -> APIClient {
#if DEBUG
return APIClient(baseURL: URL(string: "http://localhost:8080")!)
#else
return APIClient()
#endif
}
}

View File

@@ -0,0 +1,80 @@
import Foundation
import NearbyInteraction
import Combine
/// Wraps the **Nearby Interaction** (UWB) framework for Tier 1 ("Right Here")
/// handshakes.
///
/// UWB provides precise distance (~cm accuracy) and direction up to ~9 m. It
/// is **foreground-only**, which aligns perfectly with our philosophy: UWB is
/// the "look at this exact person" instrument you use while actively present.
///
/// Because UWB requires exchanging discovery tokens between two devices, the
/// actual token exchange happens via the backend relay (see `APIClient`). This
/// adapter handles the local ranging session once a peer token is known.
final class UWBAdapter: NSObject, NISessionDelegate, HandshakeAdapter {
/// Callback fired when a peer is within handshake distance.
var onHandshake: ((_ remoteToken: String, _ distance: Float) -> Void)?
private var session: NISession?
private var peerToken: NIDiscoveryToken?
private var myToken: NIDiscoveryToken?
private let queue = DispatchQueue(label: "proximity.uwb")
// MARK: - Lifecycle
func start(token: String) async {
let session = NISession()
session.delegate = self
session.delegateQueue = queue
self.session = session
// Our discovery token is shared with peers via the backend relay.
self.myToken = session.discoveryToken
}
func stop() async {
session?.invalidate()
session = nil
peerToken = nil
}
/// Begin ranging against a peer once the backend relays their token.
func beginRanging(peerToken: NIDiscoveryToken) {
guard let session else { return }
let config = NINearbyPeerConfiguration(peerToken: peerToken)
session.run(config)
}
// MARK: - NISessionDelegate
func session(_ session: NISession,
didUpdate nearbyObjects: [NINearbyObject]) {
guard let object = nearbyObjects.first else { return }
if let distance = object.distance {
// Pass the peer token string along with measured distance.
onHandshake?(peerTokenString, distance)
}
}
func session(_ session: NISession, didInvalidateWith error: Error) {
// Session invalidated (e.g. app backgrounded). Restart when foregrounded.
self.session = nil
}
func sessionWasSuspended(_ session: NISession) {
// App went to background UWB is unavailable. Pause gracefully.
self.session = nil
}
func sessionSuspensionEnded(_ session: NISession) {
// App returned to foreground we can resume.
}
private var peerTokenString: String {
// Serialize the peer's discovery token to a stable string for the relay.
peerToken?.dataRepresentation.base64EncodedString() ?? ""
}
}

View File

@@ -0,0 +1,57 @@
import Foundation
import CryptoKit
/// End-to-end encryption for revealed conversations.
///
/// Uses X25519 key agreement + ChaChaPoly AEAD, the same primitives family
/// used by Signal. The server only ever sees ciphertext and opaque key IDs
/// it cannot read message contents.
struct CryptoManager {
// MARK: - Key pair
func generateKeyPair() -> (privateKey: Curve25519.KeyAgreement.PrivateKey,
publicKey: Curve25519.KeyAgreement.PublicKey) {
let privateKey = Curve25519.KeyAgreement.PrivateKey()
return (privateKey, privateKey.publicKey)
}
// MARK: - Session establishment
/// Derive a shared symmetric session key from two key pairs.
func deriveSessionKey(
myPrivate: Curve25519.KeyAgreement.PrivateKey,
theirPublic: Curve25519.KeyAgreement.PublicKey
) throws -> SymmetricKey {
let shared = try myPrivate.sharedSecretFromKeyAgreement(with: theirPublic)
// Salt with a fixed app domain string to keep keys per-app.
return shared.hkdfDerivedSymmetricKey(
using: SHA256.self,
salt: Data("proximity.e2e.v1".utf8),
sharedInfo: Data(),
outputByteCount: 32
)
}
// MARK: - Message encryption
func encrypt(_ plaintext: Data, using key: SymmetricKey) throws -> Data {
let sealed = try ChaChaPoly.seal(plaintext, using: key)
return sealed.combined
}
func decrypt(_ combined: Data, using key: SymmetricKey) throws -> Data {
let box = try ChaChaPoly.SealedBox(combined: combined)
return try ChaChaPoly.open(box, using: key)
}
// MARK: - Signatures (for token authenticity)
func sign(_ data: Data, with key: Curve25519.Signing.PrivateKey) throws -> Data {
try key.signature(for: data)
}
func verify(_ data: Data, signature: Data, with key: Curve25519.Signing.PublicKey) -> Bool {
key.isValidSignature(signature, for: data)
}
}

View File

@@ -0,0 +1,52 @@
import Foundation
import CryptoKit
/// Manages the user's **ephemeral rotating anonymous token**.
///
/// This is the heart of the privacy model. While present, the app broadcasts a
/// token that:
/// - contains **no identity** (no name, no persistent ID),
/// - **rotates** on a short interval (e.g. every 5 minutes) so it cannot be
/// used to track the user over time,
/// - is only meaningful to the Proximity backend, which maps two tokens that
/// were physically near each other into an anonymous encounter.
///
/// The token is derived from a secret + time window, so it changes
/// deterministically without needing to re-broadcast a new random value.
struct TokenManager {
/// How long a single token remains valid before rotating.
static let rotationInterval: TimeInterval = 5 * 60 // 5 minutes
private let secret: SymmetricKey
init(secret: SymmetricKey = SymmetricKey(size: .bits256)) {
self.secret = secret
}
/// The current anonymous token for a given time window.
/// Deterministic per window so both the broadcast and the backend agree.
func currentToken(at date: Date = Date()) -> String {
let window = Int(date.timeIntervalSince1970 / Self.rotationInterval)
return token(forWindow: window)
}
/// The token for a specific rotation window.
func token(forWindow window: Int) -> String {
var data = Data()
data.append(String(window).data(using: .utf8)!)
let mac = HMAC<SHA256>.authenticationCode(for: data, using: secret)
return Data(mac).base64EncodedString()
}
/// Whether a received remote token is still within a valid rotation window.
func isValid(_ token: String, at date: Date = Date()) -> Bool {
// Tokens are valid for the current window plus one prior window,
// to tolerate clock skew between devices.
let current = Int(date.timeIntervalSince1970 / Self.rotationInterval)
for window in (current - 1)...current {
if token(forWindow: window) == token { return true }
}
return false
}
}

View File

@@ -0,0 +1,109 @@
import Foundation
import Combine
import CryptoKit
/// View model for an E2E-encrypted conversation with a revealed connection.
///
/// Messages are encrypted with the session key established at reveal time
/// (see `RevealFlow`). The server only ever sees ciphertext it cannot read
/// the conversation. This is the product's promise kept in the most intimate
/// place: the words between two people who chose to meet.
@MainActor
final class ChatViewModel: ObservableObject {
@Published private(set) var messages: [ChatMessage] = []
@Published var draft: String = ""
@Published private(set) var isSending = false
private let apiClient: APIClient
private let cryptoManager: CryptoManager
private let graphStore: GraphStore
private let realtime: RealtimeClient
/// The active connection we're chatting with.
private var connection: Connection?
/// The E2E session key for this conversation.
private var sessionKey: SymmetricKey?
/// Decrypted plaintext cache keyed by message id.
@Published private(set) var plaintextByID: [UUID: String] = [:]
private var cancellables = Set<AnyCancellable>()
init(apiClient: APIClient, cryptoManager: CryptoManager, graphStore: GraphStore, realtime: RealtimeClient) {
self.apiClient = apiClient
self.cryptoManager = cryptoManager
self.graphStore = graphStore
self.realtime = realtime
// Live wire: decrypt and append incoming messages in real time.
realtime.incomingMessage
.sink { [weak self] message in
Task { await self?.receive(message) }
}
.store(in: &cancellables)
}
// MARK: - Setup
/// Begin a conversation with a revealed connection.
func start(with connection: Connection, sessionKey: SymmetricKey?) {
self.connection = connection
self.sessionKey = sessionKey
self.messages = graphStore.messages.filter { $0.connectionID == connection.id }
}
// MARK: - Sending
func send() async {
let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty,
let connection,
let sessionKey else { return }
isSending = true
defer { isSending = false }
do {
let plaintext = Data(text.utf8)
let ciphertext = try cryptoManager.encrypt(plaintext, using: sessionKey)
let message = ChatMessage(
connectionID: connection.id,
senderID: "me",
ciphertext: ciphertext
)
messages.append(message)
plaintextByID[message.id] = text
graphStore.addMessage(message)
draft = ""
try await apiClient.sendMessage(message)
} catch {
// Keep the message locally; surface send failure to the UI.
}
}
// MARK: - Receiving
/// Decrypt and append an incoming message (called live from the socket).
func receive(_ message: ChatMessage) async {
guard let sessionKey,
message.connectionID == connection?.id else { return }
// Deduplicate against messages we already have.
guard !messages.contains(where: { $0.id == message.id }) else { return }
// Decrypt and cache the plaintext for display.
if let plaintext = try? cryptoManager.decrypt(message.ciphertext, using: sessionKey),
let text = String(data: plaintext, encoding: .utf8) {
plaintextByID[message.id] = text
messages.append(message)
graphStore.addMessage(message)
}
}
/// The decrypted text for a message, for display.
func text(for message: ChatMessage) -> String {
plaintextByID[message.id] ?? ""
}
}

View File

@@ -0,0 +1,60 @@
import Foundation
import Combine
/// View model for the presence experience.
///
/// Exposes the state the UI needs to render the "am I present?" moment, the
/// radius tier picker, and the live feed of encounters (silhouettes).
@MainActor
final class PresenceViewModel: ObservableObject {
@Published var isPresent: Bool = false
@Published var radiusTier: DistanceTier = .rightHere
@Published private(set) var activeEngines: Set<ProximityEngine> = []
@Published private(set) var encounters: [Encounter] = []
@Published private(set) var lastScan: Date?
private let engine: PresenceEngine
private var cancellables = Set<AnyCancellable>()
init(engine: PresenceEngine) {
self.engine = engine
engine.$isPresent
.assign(to: &$isPresent)
engine.$radiusTier
.assign(to: &$radiusTier)
engine.$activeEngines
.assign(to: &$activeEngines)
engine.$recentEncounters
.assign(to: &$encounters)
engine.$lastScan
.assign(to: &$lastScan)
}
// MARK: - User actions
func togglePresence() {
isPresent.toggle()
Task { await engine.setPresent(isPresent) }
}
func setRadius(_ tier: DistanceTier) {
radiusTier = tier
Task { await engine.setRadiusTier(tier) }
}
/// Send a "wave" to a silhouette the first step toward mutual reveal.
func wave(to encounter: Encounter) {
// In production: POST /v1/wave with the encounter token.
Task {
await engine.wave(to: encounter)
}
}
func block(_ encounter: Encounter) {
Task {
await engine.block(encounter)
}
}
}

View File

@@ -0,0 +1,110 @@
import SwiftUI
/// The conversation screen for a revealed connection.
///
/// This is where two people who met in the real world actually talk. The UI
/// is deliberately calm and human no engagement-baiting, no streaks, no
/// algorithms. Just a conversation between two people who chose to meet.
struct ChatView: View {
@EnvironmentObject private var vm: ChatViewModel
@EnvironmentObject private var revealFlow: RevealFlow
@State private var input = ""
let connection: Connection
var body: some View {
VStack(spacing: 0) {
// Header
HStack(spacing: 12) {
Circle()
.fill(Color.green.opacity(0.2))
.frame(width: 40, height: 40)
.overlay(Text(String(connection.displayName.prefix(1))))
VStack(alignment: .leading, spacing: 2) {
Text(connection.displayName)
.font(.headline)
Text("Met in person · \(connection.createdAt, format: .dateTime.month().day())")
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "lock.fill")
.font(.caption)
.foregroundColor(.green)
.accessibilityLabel("End-to-end encrypted")
}
.padding()
.background(Color(.secondarySystemBackground))
Divider()
// Messages
ScrollViewReader { proxy in
ScrollView {
LazyVStack(spacing: 12) {
ForEach(vm.messages) { message in
MessageBubble(message: message, isMine: message.senderID == "me")
}
}
.padding()
}
.onChange(of: vm.messages.count) { _, _ in
if let last = vm.messages.last {
withAnimation {
proxy.scrollTo(last.id, anchor: .bottom)
}
}
}
}
// Input
HStack(spacing: 12) {
TextField("Say hello…", text: $input, axis: .vertical)
.lineLimit(1...4)
.padding(10)
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 18))
Button {
vm.draft = input
input = ""
Task { await vm.send() }
} label: {
Image(systemName: "arrow.up.circle.fill")
.font(.system(size: 28))
}
.disabled(input.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding()
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.onAppear {
// Use the E2E session key established at reveal time.
vm.start(with: connection, sessionKey: revealFlow.currentSessionKey())
}
}
}
/// A single message bubble.
struct MessageBubble: View {
@EnvironmentObject private var vm: ChatViewModel
let message: ChatMessage
let isMine: Bool
var body: some View {
HStack {
if isMine { Spacer() }
Text(vm.text(for: message))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 18)
.fill(isMine ? Color.green : Color(.secondarySystemBackground))
)
.foregroundColor(isMine ? .white : .primary)
if !isMine { Spacer() }
}
}
}

View File

@@ -0,0 +1,67 @@
import SwiftUI
/// The list of revealed, mutually-consented connections.
///
/// This list is the *proof* of the product's thesis: every connection here
/// began as a real-world encounter. There is no way to add someone you've
/// never been near.
struct ConnectionsView: View {
@EnvironmentObject private var chatViewModel: ChatViewModel
// In production, sourced from GraphStore via a ConnectionsViewModel.
private let connections: [Connection] = []
var body: some View {
NavigationStack {
Group {
if connections.isEmpty {
emptyState
} else {
List(connections) { connection in
NavigationLink(value: connection) {
ConnectionRow(connection: connection)
}
}
.navigationDestination(for: Connection.self) { connection in
ChatView(connection: connection)
.environmentObject(chatViewModel)
}
}
}
.navigationTitle("Connections")
}
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "person.2")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text("No connections yet")
.font(.headline)
Text("Connections only form when you're physically near someone —\nand you both choose to reveal. Go be present.")
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
.padding(.horizontal)
}
}
}
struct ConnectionRow: View {
let connection: Connection
var body: some View {
HStack {
Circle()
.fill(Color.green.opacity(0.2))
.frame(width: 40, height: 40)
.overlay(Text(String(connection.displayName.prefix(1))))
VStack(alignment: .leading) {
Text(connection.displayName)
.font(.headline)
Text("Met \(connection.createdAt, format: .dateTime.month().day())")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}

View File

@@ -0,0 +1,18 @@
import SwiftUI
/// Root navigation. Tabs: Presence (the live feed) and Connections.
struct ContentView: View {
var body: some View {
TabView {
PresenceView()
.tabItem {
Label("Present", systemImage: "dot.radiowaves.left.and.right")
}
ConnectionsView()
.tabItem {
Label("Connections", systemImage: "person.2")
}
}
}
}

View File

@@ -0,0 +1,48 @@
import SwiftUI
/// Developer debug menu, available only in DEBUG builds.
///
/// This is the primary tool for testing the **interaction loop** without any
/// hardware. It drives a `SimulatedAdapter` inside `PresenceEngine`, letting
/// you simulate encounters, waves, and even a crowd exactly as if real
/// users were walking by. In the simulator, you can fully exercise silhouette
/// wave reveal chat with zero physical devices.
///
/// Enabled via the `#if DEBUG` guard so it never ships to production.
struct DebugMenu: View {
@EnvironmentObject private var appState: AppState
var body: some View {
#if DEBUG
NavigationStack {
Form {
Section("Simulation") {
Button("Simulate someone walking by") {
appState.simulateEncounter()
}
Button("Simulate a crowd (20 people)") {
appState.simulateCrowd()
}
Toggle("Auto-broadcast nearby users", isOn: $appState.autoBroadcast)
}
Section("Backend") {
Label("Local server (localhost:8080)", systemImage: "network")
.foregroundColor(.secondary)
Button("Reconnect socket") {
appState.restartRealtime()
}
}
Section("Info") {
Label("Simulator mode — no hardware", systemImage: "iphone.simulator")
.foregroundColor(.secondary)
}
}
.navigationTitle("Debug")
}
#else
EmptyView()
#endif
}
}

View File

@@ -0,0 +1,144 @@
import SwiftUI
/// The main presence screen.
///
/// This is the emotional core of the app. A large, tactile "I am here"
/// toggle that makes being present feel like an intentional act not a
/// passive background process. Below it, the live feed of silhouettes.
struct PresenceView: View {
@EnvironmentObject private var vm: PresenceViewModel
@EnvironmentObject private var revealFlow: RevealFlow
@State private var showReveal = false
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: 24) {
presenceToggle
radiusPicker
encounterFeed
}
.padding()
}
.navigationTitle("Proximity")
.toolbar {
#if DEBUG
ToolbarItem(placement: .topBarLeading) {
NavigationLink {
DebugMenu()
} label: {
Image(systemName: "hammer")
}
}
#endif
}
}
.onChange(of: revealFlow.phase) { _, phase in
if case .mutual = phase {
showReveal = true
}
}
.fullScreenCover(isPresented: $showReveal) {
RevealView()
.environmentObject(revealFlow)
}
}
// MARK: - The presence toggle
private var presenceToggle: some View {
Button(action: vm.togglePresence) {
ZStack {
Circle()
.fill(vm.isPresent ? Color.green : Color.gray.opacity(0.2))
.frame(width: 200, height: 200)
.overlay(
Circle()
.stroke(vm.isPresent ? Color.green : Color.gray,
lineWidth: 4)
)
VStack(spacing: 8) {
Image(systemName: vm.isPresent
? "dot.radiowaves.left.and.right"
: "person.crop.circle.dashed")
.font(.system(size: 56))
Text(vm.isPresent ? "I'm Here" : "Tap to be Present")
.font(.headline)
}
.foregroundColor(vm.isPresent ? .white : .secondary)
}
.scaleEffect(vm.isPresent ? 1.0 : 0.98)
.animation(.spring(response: 0.4, dampingFraction: 0.6),
value: vm.isPresent)
}
.buttonStyle(.plain)
.accessibilityLabel(vm.isPresent ? "You are present. Tap to go offline."
: "Go present")
}
// MARK: - Radius picker
private var radiusPicker: some View {
VStack(alignment: .leading, spacing: 8) {
Text("How far are you open to?")
.font(.subheadline)
.foregroundColor(.secondary)
Picker("Radius", selection: $vm.radiusTier) {
ForEach(DistanceTier.allCases) { tier in
Text(tier.title).tag(tier)
}
}
.pickerStyle(.segmented)
Text(vm.radiusTier.subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
}
// MARK: - Encounter feed
private var encounterFeed: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("People you've passed")
.font(.headline)
Spacer()
if let last = vm.lastScan {
Text("last scan \(last, style: .time)")
.font(.caption)
.foregroundColor(.secondary)
}
}
if vm.encounters.isEmpty {
emptyState
} else {
ForEach(vm.encounters) { encounter in
SilhouetteRow(encounter: encounter) {
// Tap a silhouette consider it wave.
revealFlow.consider(encounter)
Task { await revealFlow.wave() }
}
}
}
}
}
private var emptyState: some View {
VStack(spacing: 12) {
Image(systemName: "eye.slash")
.font(.system(size: 40))
.foregroundColor(.secondary)
Text(vm.isPresent
? "Looking for people near you…"
: "Go present to start seeing people.")
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 40)
}
}

View File

@@ -0,0 +1,126 @@
import SwiftUI
/// Shown at **reveal time** when a connection becomes mutual.
///
/// This is the only moment a social identity can be attached. The user can
/// choose to enrich their revealed profile with an Instagram or X handle
/// or decline and stay minimal. Importing is always optional and always
/// scoped to this specific connection.
struct ProfileImportSheet: View {
@EnvironmentObject private var profileService: ProfileImportService
@Environment(\.dismiss) private var dismiss
/// The connection that just became mutual.
let connection: Connection
@State private var imported: [ImportedProfile] = []
@State private var isImporting = false
@State private var errorMessage: String?
var body: some View {
NavigationStack {
VStack(spacing: 24) {
// Header
VStack(spacing: 8) {
Image(systemName: "person.crop.circle.badge.checkmark")
.font(.system(size: 48))
.foregroundColor(.green)
Text("You're connected with \(connection.displayName)")
.font(.headline)
.multilineTextAlignment(.center)
Text("Want to share a bit more about who you are?")
.font(.subheadline)
.foregroundColor(.secondary)
}
.padding(.top, 24)
// Import buttons
VStack(spacing: 12) {
ForEach(ProfileProviderID.allCases) { provider in
importButton(for: provider)
}
}
.padding(.horizontal)
// Already imported
if !imported.isEmpty {
importedList
}
Spacer()
// Done
Button("Done") { dismiss() }
.buttonStyle(.borderedProminent)
.padding(.bottom, 24)
}
.navigationTitle("Share your profile")
.navigationBarTitleDisplayMode(.inline)
}
}
private func importButton(for provider: ProfileProviderID) -> some View {
Button {
Task { await importProfile(provider) }
} label: {
HStack {
Image(systemName: provider == .instagram ? "camera" : "xmark.square")
Text("Import from \(provider.displayName)")
Spacer()
if isImporting {
ProgressView()
} else {
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.disabled(isImporting)
}
private var importedList: some View {
VStack(alignment: .leading, spacing: 8) {
Text("Imported")
.font(.subheadline)
.foregroundColor(.secondary)
ForEach(imported) { profile in
HStack {
Text("@\(profile.handle)")
.font(.headline)
Spacer()
Text(profile.providerID.displayName)
.font(.caption)
.foregroundColor(.secondary)
Button {
profileService.remove(profile)
imported.removeAll { $0.id == profile.id }
} label: {
Image(systemName: "xmark.circle")
.foregroundColor(.red)
}
}
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
.padding(.horizontal)
}
private func importProfile(_ provider: ProfileProviderID) async {
isImporting = true
defer { isImporting = false }
do {
let profile = try await profileService.importProfile(from: provider)
profileService.attach(profile, to: connection.id)
imported.append(profile)
} catch {
errorMessage = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
/// The emotional centerpiece of the app: the **reveal moment**.
///
/// Two strangers who were physically near each other both chose to be seen.
/// Their silhouettes resolve into people. This screen is deliberately
/// ceremonial it's the payoff for being present in the real world, and it
/// should feel like it.
///
/// The interaction is framed as a physical act: you were *near* this person,
/// you both *waved*, and now you *see* each other. The copy and motion are
/// designed to make this feel significant, not like a notification.
struct RevealView: View {
@EnvironmentObject private var revealFlow: RevealFlow
@Environment(\.dismiss) private var dismiss
@State private var isRevealing = false
@State private var showProfileImport = false
var body: some View {
VStack(spacing: 32) {
Spacer()
// The two avatars converging.
ZStack {
// "You" avatar (left)
avatar(systemImage: "person.fill", offset: isRevealing ? -70 : -110)
// "Them" avatar (right) resolves from silhouette to person.
avatar(systemImage: "person.fill", offset: isRevealing ? 70 : 110)
}
.frame(height: 160)
// Status text
VStack(spacing: 12) {
Text(isRevealing ? "You both waved." : "You're connected.")
.font(.title.bold())
Text(isRevealing
? "Two people who were near each other chose to be seen."
: "You can now say hello.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
Spacer()
// Actions
VStack(spacing: 12) {
Button {
revealFlow.proceedToConversation()
showProfileImport = true
} label: {
Label("Say hello", systemImage: "bubble.left.and.bubble.right")
.frame(maxWidth: .infinity)
.padding()
}
.buttonStyle(.borderedProminent)
.tint(.green)
Button("Not now") {
dismiss()
}
.foregroundColor(.secondary)
}
.padding(.horizontal)
.padding(.bottom, 24)
}
.onAppear {
// Animate the convergence on appear.
withAnimation(.easeInOut(duration: 1.2)) {
isRevealing = true
}
}
.sheet(isPresented: $showProfileImport) {
if let connection = revealFlow.revealedConnection {
ProfileImportSheet(connection: connection)
}
}
}
private func avatar(systemImage: String, offset: CGFloat) -> some View {
ZStack {
Circle()
.fill(Color.green.opacity(0.2))
.frame(width: 110, height: 110)
Image(systemName: systemImage)
.font(.system(size: 48))
.foregroundColor(.green)
}
.offset(x: offset)
.animation(.easeInOut(duration: 1.2), value: isRevealing)
}
}

View File

@@ -0,0 +1,60 @@
import SwiftUI
/// The sign-in screen.
///
/// Auth here is about **account identity** who you are to the app not
/// about what you reveal to others. The copy makes clear that signing in
/// never makes you discoverable; you must separately choose to be present.
struct SignInView: View {
@EnvironmentObject private var authService: AuthService
var body: some View {
VStack(spacing: 32) {
Spacer()
VStack(spacing: 12) {
Image(systemName: "dot.radiowaves.left.and.right")
.font(.system(size: 64))
.foregroundColor(.green)
Text("Proximity")
.font(.largeTitle.bold())
Text("A social network you can only enter\nby being physically present.")
.multilineTextAlignment(.center)
.foregroundColor(.secondary)
}
Spacer()
VStack(spacing: 12) {
SignInWithAppleButton { request in
request.requestedScopes = [.fullName, .email]
} onCompletion: { result in
// Handled via AppleAuthProvider in AuthService.
}
.frame(height: 50)
.signInWithAppleButtonStyle(.black)
Button {
Task { try? await authService.signIn(with: .google) }
} label: {
HStack {
Image(systemName: "g.circle.fill")
Text("Continue with Google")
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
.padding(.horizontal)
Text("Signing in never makes you discoverable.\nYou choose to be present separately.")
.font(.caption)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
.padding(.bottom, 24)
}
}
}

View File

@@ -0,0 +1,74 @@
import SwiftUI
/// A single anonymous encounter in the feed.
///
/// Until mutual consent, the other person is a **silhouette** a shape, not
/// a profile. This visual language is the privacy promise made visible: you
/// know a human was here, but you don't know who until they choose to be seen.
struct SilhouetteRow: View {
let encounter: Encounter
/// Called when the user taps "Wave" on a silhouette.
var onWave: () -> Void
var body: some View {
HStack(spacing: 16) {
// Silhouette avatar deliberately anonymous.
ZStack {
Circle()
.fill(Color.gray.opacity(0.25))
.frame(width: 48, height: 48)
Image(systemName: "person.fill")
.foregroundColor(.gray)
}
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
// Action depends on status.
switch encounter.status {
case .silhouette:
Button("Wave", action: onWave)
.buttonStyle(.borderedProminent)
.tint(.green)
case .waved:
Text("Waved ✓")
.font(.caption)
.foregroundColor(.green)
case .mutual:
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
case .declined:
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color(.secondarySystemBackground))
)
}
private var title: String {
switch encounter.status {
case .mutual:
return "Connected"
default:
return "Someone nearby"
}
}
private var subtitle: String {
var parts: [String] = [encounter.tier.title]
if let place = encounter.placeHint { parts.append(place) }
parts.append(encounter.timestamp.formatted(.relative(presentation: .named)))
return parts.joined(separator: " · ")
}
}