173 lines
5.9 KiB
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)
|
|
}
|
|
}
|