initial commit: Proximity iOS app proposal, full Swift code scaffold, reference backend, testing docs, generated Xcode project
This commit is contained in:
171
Proximity/App/AppState.swift
Normal file
171
Proximity/App/AppState.swift
Normal 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
|
||||
}
|
||||
41
Proximity/App/ProximityApp.swift
Normal file
41
Proximity/App/ProximityApp.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
26
Proximity/Models/ChatMessage.swift
Normal file
26
Proximity/Models/ChatMessage.swift
Normal 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
|
||||
}
|
||||
}
|
||||
35
Proximity/Models/Connection.swift
Normal file
35
Proximity/Models/Connection.swift
Normal 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
|
||||
}
|
||||
}
|
||||
49
Proximity/Models/DistanceTier.swift
Normal file
49
Proximity/Models/DistanceTier.swift
Normal 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 {
|
||||
/// 0–9 m. Ultra-wideband (Nearby Interaction). "This exact person."
|
||||
case rightHere = 1
|
||||
|
||||
/// 10–100 m. Bluetooth Low Energy / iBeacon. "This room, this block."
|
||||
case nearby = 2
|
||||
|
||||
/// 100 m–50 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 "0–9 m · UWB"
|
||||
case .nearby: return "10–100 m · BLE"
|
||||
case .inTheArea: return "100 m–50 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
|
||||
}
|
||||
}
|
||||
55
Proximity/Models/Encounter.swift
Normal file
55
Proximity/Models/Encounter.swift
Normal 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
|
||||
}
|
||||
18
Proximity/Models/ProximityEngine.swift
Normal file
18
Proximity/Models/ProximityEngine.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
21
Proximity/Models/Reveal.swift
Normal file
21
Proximity/Models/Reveal.swift
Normal 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
|
||||
}
|
||||
}
|
||||
33
Proximity/Models/UserProfile.swift
Normal file
33
Proximity/Models/UserProfile.swift
Normal 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
|
||||
)
|
||||
}
|
||||
128
Proximity/Services/Auth/AppleAuthProvider.swift
Normal file
128
Proximity/Services/Auth/AppleAuthProvider.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
46
Proximity/Services/Auth/AuthProvider.swift
Normal file
46
Proximity/Services/Auth/AuthProvider.swift
Normal 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?
|
||||
}
|
||||
102
Proximity/Services/Auth/AuthService.swift
Normal file
102
Proximity/Services/Auth/AuthService.swift
Normal 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
|
||||
}
|
||||
73
Proximity/Services/Auth/GoogleAuthProvider.swift
Normal file
73
Proximity/Services/Auth/GoogleAuthProvider.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
111
Proximity/Services/Auth/InstagramProfileProvider.swift
Normal file
111
Proximity/Services/Auth/InstagramProfileProvider.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
47
Proximity/Services/Auth/KeychainStore.swift
Normal file
47
Proximity/Services/Auth/KeychainStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
116
Proximity/Services/Auth/ProfileImportService.swift
Normal file
116
Proximity/Services/Auth/ProfileImportService.swift
Normal 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
|
||||
}
|
||||
141
Proximity/Services/Auth/XProfileProvider.swift
Normal file
141
Proximity/Services/Auth/XProfileProvider.swift
Normal 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: "")
|
||||
}
|
||||
}
|
||||
147
Proximity/Services/Network/APIClient.swift
Normal file
147
Proximity/Services/Network/APIClient.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
44
Proximity/Services/Network/GraphStore.swift
Normal file
44
Proximity/Services/Network/GraphStore.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
132
Proximity/Services/Network/RealtimeClient.swift
Normal file
132
Proximity/Services/Network/RealtimeClient.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
103
Proximity/Services/Proximity/BLEAdapter.swift
Normal file
103
Proximity/Services/Proximity/BLEAdapter.swift
Normal 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 ~10–100 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
|
||||
}
|
||||
}
|
||||
77
Proximity/Services/Proximity/BeaconAdapter.swift
Normal file
77
Proximity/Services/Proximity/BeaconAdapter.swift
Normal 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.
|
||||
}
|
||||
}
|
||||
172
Proximity/Services/Proximity/PresenceEngine.swift
Normal file
172
Proximity/Services/Proximity/PresenceEngine.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
42
Proximity/Services/Proximity/ProximityAdapterProtocols.swift
Normal file
42
Proximity/Services/Proximity/ProximityAdapterProtocols.swift
Normal 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
|
||||
}
|
||||
211
Proximity/Services/Proximity/RevealFlow.swift
Normal file
211
Proximity/Services/Proximity/RevealFlow.swift
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
110
Proximity/Services/Proximity/SimulatedAdapter.swift
Normal file
110
Proximity/Services/Proximity/SimulatedAdapter.swift
Normal 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
|
||||
}
|
||||
}
|
||||
80
Proximity/Services/Proximity/UWBAdapter.swift
Normal file
80
Proximity/Services/Proximity/UWBAdapter.swift
Normal 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() ?? ""
|
||||
}
|
||||
}
|
||||
57
Proximity/Services/Security/CryptoManager.swift
Normal file
57
Proximity/Services/Security/CryptoManager.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
52
Proximity/Services/Security/TokenManager.swift
Normal file
52
Proximity/Services/Security/TokenManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
109
Proximity/ViewModels/ChatViewModel.swift
Normal file
109
Proximity/ViewModels/ChatViewModel.swift
Normal 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] ?? "…"
|
||||
}
|
||||
}
|
||||
60
Proximity/ViewModels/PresenceViewModel.swift
Normal file
60
Proximity/ViewModels/PresenceViewModel.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
110
Proximity/Views/ChatView.swift
Normal file
110
Proximity/Views/ChatView.swift
Normal 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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
67
Proximity/Views/ConnectionsView.swift
Normal file
67
Proximity/Views/ConnectionsView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
Proximity/Views/ContentView.swift
Normal file
18
Proximity/Views/ContentView.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
48
Proximity/Views/DebugMenu.swift
Normal file
48
Proximity/Views/DebugMenu.swift
Normal 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
|
||||
}
|
||||
}
|
||||
144
Proximity/Views/PresenceView.swift
Normal file
144
Proximity/Views/PresenceView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
126
Proximity/Views/ProfileImportSheet.swift
Normal file
126
Proximity/Views/ProfileImportSheet.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
94
Proximity/Views/RevealView.swift
Normal file
94
Proximity/Views/RevealView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
60
Proximity/Views/SignInView.swift
Normal file
60
Proximity/Views/SignInView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
Proximity/Views/SilhouetteRow.swift
Normal file
74
Proximity/Views/SilhouetteRow.swift
Normal 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: " · ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user