diff --git a/MeshChatMVP.xcodeproj/project.pbxproj b/MeshChatMVP.xcodeproj/project.pbxproj index c5a18fa..c9cc8cf 100644 --- a/MeshChatMVP.xcodeproj/project.pbxproj +++ b/MeshChatMVP.xcodeproj/project.pbxproj @@ -19,6 +19,17 @@ A100000A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A200000A /* Assets.xcassets */; }; A100000B /* ChatView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A200000C /* ChatView.swift */; }; A100000C /* DebugDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A200000D /* DebugDashboardView.swift */; }; + A100000D /* Contact.swift in Sources */ = {isa = PBXBuildFile; fileRef = A200000E /* Contact.swift */; }; + A100000E /* PersistedMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A200000F /* PersistedMessage.swift */; }; + A100000F /* Alert.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000010 /* Alert.swift */; }; + A1000010 /* Vouch.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000011 /* Vouch.swift */; }; + A1000011 /* NodeSighting.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000012 /* NodeSighting.swift */; }; + A1000012 /* AlertPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000013 /* AlertPayload.swift */; }; + A1000013 /* VouchPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000014 /* VouchPayload.swift */; }; + A1000014 /* DatabaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000015 /* DatabaseManager.swift */; }; + A1000015 /* MapTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000016 /* MapTabView.swift */; }; + A1000016 /* MapLabelModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000017 /* MapLabelModels.swift */; }; + D974FBAF2F651AD7008F4874 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = D974FBAE2F651AD7008F4874 /* GRDB */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -35,6 +46,16 @@ A200000A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A200000C /* ChatView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatView.swift; sourceTree = ""; }; A200000D /* DebugDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugDashboardView.swift; sourceTree = ""; }; + A200000E /* Contact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contact.swift; sourceTree = ""; }; + A200000F /* PersistedMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistedMessage.swift; sourceTree = ""; }; + A2000010 /* Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alert.swift; sourceTree = ""; }; + A2000011 /* Vouch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Vouch.swift; sourceTree = ""; }; + A2000012 /* NodeSighting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeSighting.swift; sourceTree = ""; }; + A2000013 /* AlertPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertPayload.swift; sourceTree = ""; }; + A2000014 /* VouchPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VouchPayload.swift; sourceTree = ""; }; + A2000015 /* DatabaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseManager.swift; sourceTree = ""; }; + A2000016 /* MapTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapTabView.swift; sourceTree = ""; }; + A2000017 /* MapLabelModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MapLabelModels.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -42,6 +63,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + D974FBAF2F651AD7008F4874 /* GRDB in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -63,6 +85,7 @@ A2000002 /* ContentView.swift */, A200000C /* ChatView.swift */, A200000D /* DebugDashboardView.swift */, + A2000016 /* MapTabView.swift */, A2000003 /* BluetoothMeshService.swift */, A2000004 /* MeshEnvelope.swift */, A2000005 /* MessageType.swift */, @@ -70,6 +93,15 @@ A2000007 /* ChatPayload.swift */, A2000008 /* AnnouncementPayload.swift */, A2000009 /* ChatMessage.swift */, + A200000E /* Contact.swift */, + A200000F /* PersistedMessage.swift */, + A2000010 /* Alert.swift */, + A2000011 /* Vouch.swift */, + A2000012 /* NodeSighting.swift */, + A2000013 /* AlertPayload.swift */, + A2000014 /* VouchPayload.swift */, + A2000015 /* DatabaseManager.swift */, + A2000017 /* MapLabelModels.swift */, A200000A /* Assets.xcassets */, ); path = MeshChatMVP; @@ -127,6 +159,9 @@ Base, ); mainGroup = A4000000; + packageReferences = ( + D974FBAD2F651AD7008F4874 /* XCRemoteSwiftPackageReference "GRDB" */, + ); productRefGroup = A4000002 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -163,6 +198,16 @@ A1000007 /* ChatPayload.swift in Sources */, A1000008 /* AnnouncementPayload.swift in Sources */, A1000009 /* ChatMessage.swift in Sources */, + A100000D /* Contact.swift in Sources */, + A100000E /* PersistedMessage.swift in Sources */, + A100000F /* Alert.swift in Sources */, + A1000010 /* Vouch.swift in Sources */, + A1000011 /* NodeSighting.swift in Sources */, + A1000012 /* AlertPayload.swift in Sources */, + A1000013 /* VouchPayload.swift in Sources */, + A1000014 /* DatabaseManager.swift in Sources */, + A1000015 /* MapTabView.swift in Sources */, + A1000016 /* MapLabelModels.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -218,7 +263,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6VX7H735DM; + DEVELOPMENT_TEAM = U4P4H7KRKC; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = MeshChat; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "MeshChat uses Bluetooth LE to discover nearby devices and relay messages."; @@ -245,7 +290,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = 6VX7H735DM; + DEVELOPMENT_TEAM = U4P4H7KRKC; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_CFBundleDisplayName = MeshChat; INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "MeshChat uses Bluetooth LE to discover nearby devices and relay messages."; @@ -288,6 +333,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + D974FBAD2F651AD7008F4874 /* XCRemoteSwiftPackageReference "GRDB" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/groue/GRDB.swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 6.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + D974FBAE2F651AD7008F4874 /* GRDB */ = { + isa = XCSwiftPackageProductDependency; + package = D974FBAD2F651AD7008F4874 /* XCRemoteSwiftPackageReference "GRDB" */; + productName = GRDB; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = A6000001 /* Project object */; } diff --git a/MeshChatMVP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/MeshChatMVP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..80ea21e --- /dev/null +++ b/MeshChatMVP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "d77223ea3cadaebd2154378ec5005b6ebefcef3b34a4dafa368b0c4f16c0561c", + "pins" : [ + { + "identity" : "grdb.swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/groue/GRDB.swift", + "state" : { + "revision" : "2cf6c756e1e5ef6901ebae16576a7e4e4b834622", + "version" : "6.29.3" + } + } + ], + "version" : 3 +} diff --git a/MeshChatMVP/Alert.swift b/MeshChatMVP/Alert.swift new file mode 100644 index 0000000..d782902 --- /dev/null +++ b/MeshChatMVP/Alert.swift @@ -0,0 +1,38 @@ +import Foundation +import GRDB + +struct Alert: Codable, FetchableRecord, PersistableRecord, Identifiable { + static let databaseTableName = "alerts" + + var id: String + var authorID: String // FK → contacts.id + var type: AlertType + var severity: Int // 1 = low, 2 = medium, 3 = high + var lat: Double + var lon: Double + var description: String + var createdAt: Int64 // unix timestamp (seconds) + var expiresAt: Int64 // unix timestamp (seconds) + var trustScore: Double // 0.0–1.0, computed and cached + + enum AlertType: String, Codable { + case hazard // negative — danger, threat + case aid // positive — help, resources + case other // neutral — info + } + + var isExpired: Bool { + Int64(Date().timeIntervalSince1970) > expiresAt + } + + /// Default expiry offset in seconds based on alert type. + static func defaultExpiresAt(for type: AlertType) -> Int64 { + let offset: Int64 + switch type { + case .hazard: offset = 6 * 3600 // 6 hours + case .aid: offset = 24 * 3600 // 24 hours + case .other: offset = 12 * 3600 // 12 hours + } + return Int64(Date().timeIntervalSince1970) + offset + } +} diff --git a/MeshChatMVP/AlertPayload.swift b/MeshChatMVP/AlertPayload.swift new file mode 100644 index 0000000..2eb1085 --- /dev/null +++ b/MeshChatMVP/AlertPayload.swift @@ -0,0 +1,13 @@ +import Foundation + +/// Wire payload for a `.alert` envelope. +struct AlertPayload: Codable { + var alertID: String + var type: Alert.AlertType + var severity: Int + var lat: Double + var lon: Double + var description: String + var createdAt: Int64 // unix timestamp (seconds) + var expiresAt: Int64 // unix timestamp (seconds) +} diff --git a/MeshChatMVP/BluetoothMeshService.swift b/MeshChatMVP/BluetoothMeshService.swift index 311b94d..3810c05 100644 --- a/MeshChatMVP/BluetoothMeshService.swift +++ b/MeshChatMVP/BluetoothMeshService.swift @@ -39,6 +39,11 @@ final class BluetoothMeshService: NSObject, ObservableObject { /// Sender ID → (lat, lon) from received envelopes (only when senders share location). @Published var senderCoordinates: [String: (lat: Double, lon: Double)] = [:] + /// Map labels (id → payload); shared via .mapLabel envelopes. + @Published var mapLabels: [UUID: MapLabelPayload] = [:] + /// Label votes: labelId → (voterID → 1 or -1); shared via .mapLabelVote envelopes. + @Published var labelVotes: [UUID: [String: Int]] = [:] + /// Battery-friendly: scan only during windows; idle between. @Published var isScanning: Bool = false @Published var scanWindowSeconds: Double = 12 @@ -122,6 +127,7 @@ final class BluetoothMeshService: NSObject, ObservableObject { if identity.shareLocation { locationManager.startUpdatingLocation() } + loadPersistedMessages() bleQueue.async { [weak self] in self?.setupPeripheralIfPowered() self?.scheduleScanCycle() @@ -194,6 +200,19 @@ final class BluetoothMeshService: NSObject, ObservableObject { log("connect(\(reason)) → \(id)") } + /// Load the last 200 broadcast messages from the DB into the in-memory chat list. + private func loadPersistedMessages() { + let db = DatabaseManager.shared + guard let persisted = try? db.messages(channel: "broadcast", limit: 200) else { return } + let myID = identity.deviceID + let loaded = persisted.map { m in + m.toChatMessage(isLocal: m.senderID == myID) + } + DispatchQueue.main.async { [weak self] in + self?.chatMessages = loaded + } + } + func sendChat(text: String) { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } @@ -219,10 +238,146 @@ final class BluetoothMeshService: NSObject, ObservableObject { env.senderLatitude = loc.lat env.senderLongitude = loc.lon } + let persisted = PersistedMessage( + id: env.id.uuidString, + senderID: env.senderID, + senderName: env.senderName, + text: trimmed, + timestamp: Int64(env.timestamp), + channel: "broadcast", + receivedAt: Int64(Date().timeIntervalSince1970) + ) + try? DatabaseManager.shared.upsertContact(id: identity.deviceID, nickname: identity.nickname) + try? DatabaseManager.shared.saveMessage(persisted) appendLocalChat(envelope: env, text: trimmed) broadcastEnvelope(env, excludeCentral: nil, excludePeripheral: nil) } + func sendAlert(type: Alert.AlertType, severity: Int, lat: Double, lon: Double, description: String) { + let now = Int64(Date().timeIntervalSince1970) + let alertID = UUID().uuidString + let payload = AlertPayload( + alertID: alertID, + type: type, + severity: severity, + lat: lat, + lon: lon, + description: description, + createdAt: now, + expiresAt: Alert.defaultExpiresAt(for: type) + ) + guard let payloadData = try? JSONEncoder().encode(payload) else { return } + let env = MeshEnvelope( + id: UUID(), + type: .alert, + senderID: identity.deviceID, + senderName: identity.nickname, + timestamp: UInt64(now * 1000), + ttl: defaultTTL, + payload: payloadData, + senderLatitude: identity.shareLocation ? lastKnownLocation?.lat : nil, + senderLongitude: identity.shareLocation ? lastKnownLocation?.lon : nil + ) + // Persist locally before broadcasting + let alert = Alert( + id: alertID, + authorID: identity.deviceID, + type: type, + severity: severity, + lat: lat, + lon: lon, + description: description, + createdAt: now, + expiresAt: payload.expiresAt, + trustScore: 1.0 // author fully trusts their own alert + ) + try? DatabaseManager.shared.upsertContact(id: identity.deviceID, nickname: identity.nickname) + try? DatabaseManager.shared.saveAlert(alert) + broadcastEnvelope(env, excludeCentral: nil, excludePeripheral: nil) + log("Alert sent: \(type.rawValue) sev=\(severity)") + } + + func sendVouch(alertID: String, confirms: Bool) { + let value = confirms ? 1 : -1 + let payload = VouchPayload(alertID: alertID, value: value) + guard let payloadData = try? JSONEncoder().encode(payload) else { return } + let env = MeshEnvelope( + id: UUID(), + type: .vouch, + senderID: identity.deviceID, + senderName: identity.nickname, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + ttl: defaultTTL, + payload: payloadData, + senderLatitude: nil, + senderLongitude: nil + ) + let vouch = Vouch(alertID: alertID, voucherID: identity.deviceID, + value: value, timestamp: Int64(Date().timeIntervalSince1970)) + try? DatabaseManager.shared.upsertContact(id: identity.deviceID, nickname: identity.nickname) + try? DatabaseManager.shared.saveVouch(vouch) + try? DatabaseManager.shared.recomputeTrustScore(alertID: alertID) + broadcastEnvelope(env, excludeCentral: nil, excludePeripheral: nil) + log("Vouch sent: alertID=\(alertID) value=\(value)") + } + + func sendMapLabel(category: LabelCategory, lat: Double, lon: Double) { + let payload = MapLabelPayload( + id: UUID(), + category: category.rawValue, + lat: lat, + lon: lon, + senderID: identity.deviceID, + senderName: identity.nickname, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000) + ) + guard let payloadData = try? JSONEncoder().encode(payload) else { return } + var env = MeshEnvelope( + id: UUID(), + type: .mapLabel, + senderID: identity.deviceID, + senderName: identity.nickname, + timestamp: payload.timestamp, + ttl: defaultTTL, + payload: payloadData, + senderLatitude: nil, + senderLongitude: nil + ) + if identity.shareLocation, let loc = lastKnownLocation { + env.senderLatitude = loc.lat + env.senderLongitude = loc.lon + } + DispatchQueue.main.async { [weak self] in + self?.mapLabels[payload.id] = payload + } + broadcastEnvelope(env, excludeCentral: nil, excludePeripheral: nil) + } + + func voteForLabel(labelId: UUID, up: Bool) { + let vote = up ? 1 : -1 + let payload = MapLabelVotePayload(labelId: labelId, vote: vote, voterID: identity.deviceID) + guard let payloadData = try? JSONEncoder().encode(payload) else { return } + let env = MeshEnvelope( + id: UUID(), + type: .mapLabelVote, + senderID: identity.deviceID, + senderName: identity.nickname, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + ttl: defaultTTL, + payload: payloadData, + senderLatitude: nil, + senderLongitude: nil + ) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if self.labelVotes[labelId] == nil { + self.labelVotes[labelId] = [:] + } + self.labelVotes[labelId]?[self.identity.deviceID] = vote + } + broadcastEnvelope(env, excludeCentral: nil, excludePeripheral: nil) + } + func clearDebugLog() { DispatchQueue.main.async { [weak self] in self?.debugLines = [] @@ -408,6 +563,9 @@ final class BluetoothMeshService: NSObject, ObservableObject { log("Drop dedup \(env.id)") return } + // Always upsert the sender as a contact + try? DatabaseManager.shared.upsertContact(id: env.senderID, nickname: env.senderName) + switch env.type { case .announce: if let a = try? JSONDecoder().decode(AnnouncementPayload.self, from: env.payload) { @@ -419,6 +577,16 @@ final class BluetoothMeshService: NSObject, ObservableObject { case .message: if let chat = try? JSONDecoder().decode(ChatPayload.self, from: env.payload) { let envCopy = env + let persisted = PersistedMessage( + id: env.id.uuidString, + senderID: env.senderID, + senderName: env.senderName, + text: chat.text, + timestamp: Int64(env.timestamp), + channel: "broadcast", + receivedAt: Int64(Date().timeIntervalSince1970) + ) + try? DatabaseManager.shared.saveMessage(persisted) DispatchQueue.main.async { [weak self] in guard let self else { return } let distance = self.distanceFromMe(senderID: envCopy.senderID) @@ -436,11 +604,58 @@ final class BluetoothMeshService: NSObject, ObservableObject { ) } } + case .alert: + if let payload = try? JSONDecoder().decode(AlertPayload.self, from: env.payload) { + let alert = Alert( + id: payload.alertID, + authorID: env.senderID, + type: payload.type, + severity: payload.severity, + lat: payload.lat, + lon: payload.lon, + description: payload.description, + createdAt: payload.createdAt, + expiresAt: payload.expiresAt, + trustScore: 0.0 + ) + try? DatabaseManager.shared.saveAlert(alert) + log("Alert received: \(payload.type.rawValue) sev=\(payload.severity) from \(env.senderName)") + } + case .vouch: + if let payload = try? JSONDecoder().decode(VouchPayload.self, from: env.payload) { + let vouch = Vouch( + alertID: payload.alertID, + voucherID: env.senderID, + value: payload.value, + timestamp: Int64(Date().timeIntervalSince1970) + ) + try? DatabaseManager.shared.saveVouch(vouch) + try? DatabaseManager.shared.recomputeTrustScore(alertID: payload.alertID) + log("Vouch received: alertID=\(payload.alertID) value=\(payload.value) from \(env.senderName)") + } + case .mapLabel: + if let payload = try? JSONDecoder().decode(MapLabelPayload.self, from: env.payload) { + DispatchQueue.main.async { [weak self] in + self?.mapLabels[payload.id] = payload + } + } + case .mapLabelVote: + if let payload = try? JSONDecoder().decode(MapLabelVotePayload.self, from: env.payload), + payload.vote == 1 || payload.vote == -1 { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if self.labelVotes[payload.labelId] == nil { + self.labelVotes[payload.labelId] = [:] + } + self.labelVotes[payload.labelId]?[payload.voterID] = payload.vote + } + } } if let lat = env.senderLatitude, let lon = env.senderLongitude { DispatchQueue.main.async { [weak self] in self?.senderCoordinates[env.senderID] = (lat, lon) } + try? DatabaseManager.shared.recordSighting(nodeID: env.senderID, lat: lat, lon: lon, rssi: 0) } if env.ttl > 1 { var relay = env diff --git a/MeshChatMVP/Contact.swift b/MeshChatMVP/Contact.swift new file mode 100644 index 0000000..681195f --- /dev/null +++ b/MeshChatMVP/Contact.swift @@ -0,0 +1,18 @@ +import Foundation +import GRDB + +struct Contact: Codable, FetchableRecord, PersistableRecord, Identifiable { + static let databaseTableName = "contacts" + + var id: String // deviceID (UUID string) + var nickname: String + var relationship: Relationship + var firstSeen: Int64 // unix timestamp + var lastSeen: Int64 + var publicKey: Data? // reserved for future signed messages + + enum Relationship: String, Codable { + case associate // heard from on mesh; low trust + case friend // mutually vouched; high trust + } +} diff --git a/MeshChatMVP/ContentView.swift b/MeshChatMVP/ContentView.swift index 7936a10..803d31c 100644 --- a/MeshChatMVP/ContentView.swift +++ b/MeshChatMVP/ContentView.swift @@ -9,6 +9,9 @@ struct ContentView: View { ChatView() .tabItem { Label("Chat", systemImage: "bubble.left.and.bubble.right") } + MapTabView() + .tabItem { Label("Map", systemImage: "map") } + DebugDashboardView() .tabItem { Label("Dashboard", systemImage: "square.grid.2x2") } diff --git a/MeshChatMVP/DatabaseManager.swift b/MeshChatMVP/DatabaseManager.swift new file mode 100644 index 0000000..ebe71b1 --- /dev/null +++ b/MeshChatMVP/DatabaseManager.swift @@ -0,0 +1,218 @@ +import Foundation +import GRDB + +/// Manages the SQLite database via GRDB. Thread-safe via DatabaseQueue. +final class DatabaseManager { + static let shared = DatabaseManager() + + private let dbQueue: DatabaseQueue + + private init() { + let url = try! FileManager.default + .url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + .appendingPathComponent("mesh.sqlite") + dbQueue = try! DatabaseQueue(path: url.path) + try! runMigrations() + } + + private func runMigrations() throws { + var migrator = DatabaseMigrator() + + migrator.registerMigration("v1") { db in + try db.create(table: "contacts") { t in + t.primaryKey("id", .text) + t.column("nickname", .text).notNull() + t.column("relationship", .text).notNull().defaults(to: "associate") + t.column("firstSeen", .integer).notNull() + t.column("lastSeen", .integer).notNull() + t.column("publicKey", .blob) + } + + try db.create(table: "messages") { t in + t.primaryKey("id", .text) + t.column("senderID", .text).notNull() + t.column("senderName", .text).notNull() + t.column("text", .text).notNull() + t.column("timestamp", .integer).notNull() + t.column("channel", .text).notNull().defaults(to: "broadcast") + t.column("receivedAt", .integer).notNull() + } + + try db.create(table: "alerts") { t in + t.primaryKey("id", .text) + t.column("authorID", .text).notNull() + t.column("type", .text).notNull() + t.column("severity", .integer).notNull() + t.column("lat", .double).notNull() + t.column("lon", .double).notNull() + t.column("description", .text).notNull() + t.column("createdAt", .integer).notNull() + t.column("expiresAt", .integer).notNull() + t.column("trustScore", .double).notNull().defaults(to: 0.0) + } + + try db.create(table: "vouches") { t in + t.column("alertID", .text).notNull() + t.column("voucherID", .text).notNull() + t.column("value", .integer).notNull() + t.column("timestamp", .integer).notNull() + t.primaryKey(["alertID", "voucherID"]) + } + + try db.create(table: "nodeSightings") { t in + t.autoIncrementedPrimaryKey("id") + t.column("nodeID", .text).notNull() + t.column("lat", .double).notNull() + t.column("lon", .double).notNull() + t.column("timestamp", .integer).notNull() + t.column("rssi", .integer).notNull() + } + } + + try migrator.migrate(dbQueue) + } +} + +// MARK: - Contacts + +extension DatabaseManager { + /// Insert or update a contact. Preserves existing relationship level on update. + func upsertContact(id: String, nickname: String, defaultRelationship: Contact.Relationship = .associate) throws { + try dbQueue.write { db in + let now = Int64(Date().timeIntervalSince1970) + if var existing = try Contact.fetchOne(db, key: id) { + existing.nickname = nickname + existing.lastSeen = now + try existing.update(db) + } else { + try Contact(id: id, nickname: nickname, relationship: defaultRelationship, + firstSeen: now, lastSeen: now, publicKey: nil).insert(db) + } + } + } + + func setRelationship(_ relationship: Contact.Relationship, for contactID: String) throws { + try dbQueue.write { db in + if var contact = try Contact.fetchOne(db, key: contactID) { + contact.relationship = relationship + try contact.update(db) + } + } + } + + func allContacts() throws -> [Contact] { + try dbQueue.read { db in + try Contact.order(Column("lastSeen").desc).fetchAll(db) + } + } + + func contact(id: String) throws -> Contact? { + try dbQueue.read { db in + try Contact.fetchOne(db, key: id) + } + } +} + +// MARK: - Messages + +extension DatabaseManager { + func saveMessage(_ msg: PersistedMessage) throws { + try dbQueue.write { db in + try msg.insert(db, onConflict: .ignore) + } + } + + /// Returns messages ordered oldest → newest for display. + func messages(channel: String = "broadcast", limit: Int = 200) throws -> [PersistedMessage] { + try dbQueue.read { db in + try PersistedMessage + .filter(Column("channel") == channel) + .order(Column("timestamp").asc) + .limit(limit) + .fetchAll(db) + } + } +} + +// MARK: - Alerts + +extension DatabaseManager { + func saveAlert(_ alert: Alert) throws { + try dbQueue.write { db in + try alert.insert(db, onConflict: .replace) + } + } + + func activeAlerts() throws -> [Alert] { + let now = Int64(Date().timeIntervalSince1970) + return try dbQueue.read { db in + try Alert + .filter(Column("expiresAt") > now) + .order(Column("createdAt").desc) + .fetchAll(db) + } + } + + func alert(id: String) throws -> Alert? { + try dbQueue.read { db in + try Alert.fetchOne(db, key: id) + } + } + + /// Recomputes and persists the trust score for an alert based on its vouches. + /// Each vouch is weighted equally for now; friends-of-friends weighting added in Phase 3. + func recomputeTrustScore(alertID: String) throws { + try dbQueue.write { db in + guard var alert = try Alert.fetchOne(db, key: alertID) else { return } + let vouches = try Vouch.filter(Column("alertID") == alertID).fetchAll(db) + // Weight: each vouch ±0.2, clamped to [0, 1] + let raw = vouches.reduce(0.0) { acc, v in acc + Double(v.value) * 0.2 } + alert.trustScore = min(1.0, max(0.0, raw)) + try alert.update(db) + } + } +} + +// MARK: - Vouches + +extension DatabaseManager { + func saveVouch(_ vouch: Vouch) throws { + try dbQueue.write { db in + try vouch.insert(db, onConflict: .replace) + } + } + + func vouches(for alertID: String) throws -> [Vouch] { + try dbQueue.read { db in + try Vouch.filter(Column("alertID") == alertID).fetchAll(db) + } + } +} + +// MARK: - Node Sightings + +extension DatabaseManager { + func recordSighting(nodeID: String, lat: Double, lon: Double, rssi: Int) throws { + var sighting = NodeSighting( + id: nil, + nodeID: nodeID, + lat: lat, + lon: lon, + timestamp: Int64(Date().timeIntervalSince1970), + rssi: rssi + ) + try dbQueue.write { db in + try sighting.insert(db) + } + } + + func recentSightings(for nodeID: String, limit: Int = 10) throws -> [NodeSighting] { + try dbQueue.read { db in + try NodeSighting + .filter(Column("nodeID") == nodeID) + .order(Column("timestamp").desc) + .limit(limit) + .fetchAll(db) + } + } +} diff --git a/MeshChatMVP/MapLabelModels.swift b/MeshChatMVP/MapLabelModels.swift new file mode 100644 index 0000000..52cd049 --- /dev/null +++ b/MeshChatMVP/MapLabelModels.swift @@ -0,0 +1,47 @@ +import Foundation + +enum LabelCategory: String, Codable, CaseIterable, Identifiable { + case hazard + case resource + case info + case meeting + case danger + + var id: String { rawValue } + + var displayName: String { + switch self { + case .hazard: return "Hazard" + case .resource: return "Resource" + case .info: return "Info" + case .meeting: return "Meeting" + case .danger: return "Danger" + } + } + + var systemImage: String { + switch self { + case .hazard: return "exclamationmark.triangle.fill" + case .resource: return "shippingbox.fill" + case .info: return "info.circle.fill" + case .meeting: return "person.2.fill" + case .danger: return "xmark.octagon.fill" + } + } +} + +struct MapLabelPayload: Codable, Identifiable { + var id: UUID + var category: String + var lat: Double + var lon: Double + var senderID: String + var senderName: String + var timestamp: UInt64 +} + +struct MapLabelVotePayload: Codable { + var labelId: UUID + var vote: Int + var voterID: String +} diff --git a/MeshChatMVP/MapTabView.swift b/MeshChatMVP/MapTabView.swift new file mode 100644 index 0000000..46a964e --- /dev/null +++ b/MeshChatMVP/MapTabView.swift @@ -0,0 +1,125 @@ +import SwiftUI +import MapKit + +struct MapTabView: View { + @EnvironmentObject var mesh: BluetoothMeshService + @State private var region = MKCoordinateRegion( + center: CLLocationCoordinate2D(latitude: 37.7749, longitude: -122.4194), + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + ) + @State private var selectedCategory: LabelCategory = .info + @State private var showingAddSheet = false + + var body: some View { + NavigationStack { + ZStack(alignment: .bottomTrailing) { + Map(coordinateRegion: $region, annotationItems: Array(mesh.mapLabels.values)) { label in + MapAnnotation(coordinate: CLLocationCoordinate2D(latitude: label.lat, longitude: label.lon)) { + LabelAnnotationView(label: label, votes: mesh.labelVotes[label.id] ?? [:]) + } + } + .ignoresSafeArea(edges: .top) + + Button { + showingAddSheet = true + } label: { + Image(systemName: "plus.circle.fill") + .font(.system(size: 44)) + .foregroundStyle(.white, .blue) + .shadow(radius: 4) + } + .padding() + } + .navigationTitle("Map") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $showingAddSheet) { + AddLabelSheet(region: region) { category, lat, lon in + mesh.sendMapLabel(category: category, lat: lat, lon: lon) + } + } + .onAppear { + if let loc = mesh.lastKnownLocation { + region.center = CLLocationCoordinate2D(latitude: loc.lat, longitude: loc.lon) + } + } + } + } +} + +private struct LabelAnnotationView: View { + let label: MapLabelPayload + let votes: [String: Int] + @EnvironmentObject var mesh: BluetoothMeshService + + private var netVotes: Int { + votes.values.reduce(0, +) + } + + private var category: LabelCategory? { + LabelCategory(rawValue: label.category) + } + + var body: some View { + VStack(spacing: 2) { + Image(systemName: category?.systemImage ?? "mappin.circle.fill") + .font(.title2) + .foregroundStyle(netVotes < -2 ? .gray : .red) + + HStack(spacing: 4) { + Button { mesh.voteForLabel(labelId: label.id, up: true) } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.caption) + } + Text("\(netVotes)") + .font(.caption2) + .bold() + Button { mesh.voteForLabel(labelId: label.id, up: false) } label: { + Image(systemName: "arrow.down.circle.fill") + .font(.caption) + } + } + } + } +} + +private struct AddLabelSheet: View { + let region: MKCoordinateRegion + let onAdd: (LabelCategory, Double, Double) -> Void + @Environment(\.dismiss) private var dismiss + @State private var selected: LabelCategory = .info + + var body: some View { + NavigationStack { + Form { + Section("Category") { + Picker("Type", selection: $selected) { + ForEach(LabelCategory.allCases) { cat in + Label(cat.displayName, systemImage: cat.systemImage) + .tag(cat) + } + } + .pickerStyle(.inline) + .labelsHidden() + } + Section("Location") { + Text("Will drop at map center: \(region.center.latitude, specifier: "%.4f"), \(region.center.longitude, specifier: "%.4f")") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .navigationTitle("Add Label") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Drop") { + onAdd(selected, region.center.latitude, region.center.longitude) + dismiss() + } + } + } + } + } +} diff --git a/MeshChatMVP/MessageType.swift b/MeshChatMVP/MessageType.swift index 3ead4ef..c395773 100644 --- a/MeshChatMVP/MessageType.swift +++ b/MeshChatMVP/MessageType.swift @@ -1,7 +1,11 @@ import Foundation -/// Wire message kinds — aligned with BitChat-style announce + chat only (MVP). +/// Wire message kinds. enum MessageType: UInt8, Codable, CaseIterable { - case announce = 1 - case message = 2 + case announce = 1 + case message = 2 + case alert = 3 + case vouch = 4 + case mapLabel = 5 + case mapLabelVote = 6 } diff --git a/MeshChatMVP/NodeSighting.swift b/MeshChatMVP/NodeSighting.swift new file mode 100644 index 0000000..79a4aa4 --- /dev/null +++ b/MeshChatMVP/NodeSighting.swift @@ -0,0 +1,18 @@ +import Foundation +import GRDB + +/// Records when and where a node was observed. Used for proximity-based trust. +struct NodeSighting: Codable, FetchableRecord, MutablePersistableRecord { + static let databaseTableName = "nodeSightings" + + var id: Int64? // auto-increment rowid + var nodeID: String // FK → contacts.id + var lat: Double + var lon: Double + var timestamp: Int64 // unix timestamp (seconds) + var rssi: Int + + mutating func didInsert(_ inserted: InsertionSuccess) { + id = inserted.rowID + } +} diff --git a/MeshChatMVP/PersistedMessage.swift b/MeshChatMVP/PersistedMessage.swift new file mode 100644 index 0000000..58d9f64 --- /dev/null +++ b/MeshChatMVP/PersistedMessage.swift @@ -0,0 +1,31 @@ +import Foundation +import GRDB + +/// Persistent store for chat messages. Separate from ChatMessage (UI model). +struct PersistedMessage: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName = "messages" + + var id: String // envelope UUID (dedup key) + var senderID: String // FK → contacts.id + var senderName: String // denormalized for display without join + var text: String + var timestamp: Int64 // sender timestamp (ms since epoch) + var channel: String // "broadcast" or a DM thread id + var receivedAt: Int64 // local unix timestamp (seconds) +} + +extension PersistedMessage { + /// Convert to the UI model used by ChatView. + func toChatMessage(distanceFromMe: Double? = nil, isLocal: Bool = false) -> ChatMessage { + ChatMessage( + id: UUID(), + envelopeId: UUID(uuidString: id) ?? UUID(), + senderID: senderID, + senderName: senderName, + text: text, + date: Date(timeIntervalSince1970: Double(receivedAt)), + isLocal: isLocal, + distanceFromMe: distanceFromMe + ) + } +} diff --git a/MeshChatMVP/Vouch.swift b/MeshChatMVP/Vouch.swift new file mode 100644 index 0000000..996c175 --- /dev/null +++ b/MeshChatMVP/Vouch.swift @@ -0,0 +1,13 @@ +import Foundation +import GRDB + +/// A node's vote on an alert's validity. +/// Composite primary key: (alertID, voucherID) — one vouch per node per alert. +struct Vouch: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName = "vouches" + + var alertID: String // FK → alerts.id + var voucherID: String // FK → contacts.id + var value: Int // 1 = confirms, -1 = denies + var timestamp: Int64 // unix timestamp (seconds) +} diff --git a/MeshChatMVP/VouchPayload.swift b/MeshChatMVP/VouchPayload.swift new file mode 100644 index 0000000..83ab4ee --- /dev/null +++ b/MeshChatMVP/VouchPayload.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Wire payload for a `.vouch` envelope. +struct VouchPayload: Codable { + var alertID: String + var value: Int // 1 = confirms, -1 = denies +}