Files
Nearfield_Friends/Proximity/Services/Proximity/PresenceEngine.swift

173 lines
5.9 KiB
Swift

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)
}
}