Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
b72f4de
Validate packet inputs and fail closed on malformed responses
robekl Mar 21, 2026
f57db07
Fix MeshCoreSession response and error miscorrelation
robekl Mar 23, 2026
863bbf3
Merge pull request #264 from robekl/fix/meshcore-session-response-cor…
Avi0n Mar 23, 2026
c972bc8
feat(l10n): add Saved History localization strings
Avi0n Mar 20, 2026
3898bfa
feat(telemetry): add TelemetryHistoryOverviewViewModel with tests
Avi0n Mar 20, 2026
2fdd68a
test(telemetry): add channelGroups and computed property tests
Avi0n Mar 20, 2026
81509b6
feat(telemetry): add TelemetryHistoryOverviewView with chart sections
Avi0n Mar 20, 2026
adb2a84
feat(contacts): add Saved History button to ContactDetailView
Avi0n Mar 20, 2026
0813016
feat(regions): add anonymous region request support
Avi0n Mar 22, 2026
cffa880
feat(regions): add region filtering UI and management
Avi0n Mar 25, 2026
bc2ab84
fix(regions): restore flood routing if transport.send throws
Avi0n Mar 25, 2026
3abf8df
ui(ble-menu): move Advanced Settings below battery in radio menu
Avi0n Mar 25, 2026
7f7a004
fix(tests): align lastHeard sort test with lastModified sort key
Avi0n Mar 25, 2026
9a68ecf
l10n(contacts): rename sort option from Last Heard to Last Modified
Avi0n Mar 25, 2026
1c315b9
Merge pull request #263 from robekl/fix-packetbuilder-validation
Avi0n Mar 25, 2026
b4ee628
fix(chats): force TextField re-creation on send to clear reliably
Avi0n Mar 25, 2026
a976a77
fix(meshcore): decode room server status layout
robekl Mar 25, 2026
4751e77
fix(meshcore): handle disabled export private key
robekl Mar 25, 2026
bd8c38e
fix(repeater): prioritize query hint over deviceTime heuristic in CLI…
Avi0n Mar 25, 2026
0457d42
feat(repeater): improve telemetry history views
Avi0n Mar 26, 2026
ae84423
Merge pull request #267 from robekl/fix/room-server-status-parsing
Avi0n Mar 26, 2026
782c2d6
fix(meshcore): decode 0x87 push status for room server layout
Avi0n Mar 26, 2026
9ec947d
feat(meshcore): add binary owner info request
Avi0n Mar 26, 2026
63e0e60
feat(repeater): add guest mode for repeater access
Avi0n Mar 26, 2026
ba12bcb
feat(repeater): pre-fetch node info via binary in admin panel
Avi0n Mar 26, 2026
afc8c6c
refactor(repeater): rename adminAccess l10n key to management
Avi0n Mar 26, 2026
707bbeb
fix(notifications): suppress low battery alerts for batteryless devices
Avi0n Mar 26, 2026
fc78111
fix(chats): keep keyboard visible after send and clear ghost-text rel…
Avi0n Mar 26, 2026
0e07b65
fix(chats): keep keyboard visible when sending first message
Avi0n Mar 26, 2026
0419bd1
feat(repeater): add region configuration to management view
Avi0n Mar 26, 2026
91cc072
feat(contacts): add paste URL option to Add Contact sheet
Avi0n Mar 26, 2026
c825337
fix(chats): decode pathLength before displaying hop count and direct …
Avi0n Mar 27, 2026
404b4e7
feat(location): use device location as fallback when phone location u…
Avi0n Mar 27, 2026
dff5db5
refactor(signal): unify LoRa SNR quality tiers
Avi0n Mar 27, 2026
692c2ca
ui(ble-menu): cap dynamic type size at xLarge
Avi0n Mar 27, 2026
007c4d2
Adjust SNR thresholds
neilalexander Mar 27, 2026
72aace8
Merge pull request #269 from neilalexander/snr
Avi0n Mar 27, 2026
68ca214
fix(ui): fix inconsistent x-axis date format in telemetry charts
Avi0n Mar 27, 2026
b8f1ba7
Merge pull request #268 from robekl/fix/export-private-key-disabled
Avi0n Mar 27, 2026
06c59a8
Extract shared node helpers, add Room management UI (#270)
Avi0n Mar 27, 2026
296c614
feat(map): migrate from MapKit to MapLibre Native (#253)
Avi0n Mar 28, 2026
aa45ac6
feat(ble): add GAT562, M5Stack, and ThinkNode M5 device platform rules
Avi0n Mar 28, 2026
d4df4bf
fix(battery): update R1 Neo and LTO OCV curves from measured data
Avi0n Mar 28, 2026
0d89e48
ui(contacts): use consistent hop icon in contact detail view
Avi0n Mar 28, 2026
6da48a6
feat(discover): show hop count and path in discovery rows
Avi0n Mar 28, 2026
a7c41da
feat(repeater): add discover neighbours button to status view
Avi0n Mar 28, 2026
a706107
feat(los): show obstruction markers on map
Avi0n Mar 28, 2026
8a1fa00
fix(signal): sync uiColor with updated SNR tier colors
Avi0n Mar 28, 2026
39b1cc9
ui(trace): remove empty state overlay from map view
Avi0n Mar 28, 2026
c8a08fe
fix(block): discard channel messages from blocked senders at ingestion
Avi0n Mar 28, 2026
c1fa204
ui(contacts): rename "Ping" to "Zero-Hop Ping" in contact details
Avi0n Mar 28, 2026
765ba85
fix(app): survive background launch before first unlock
Avi0n Mar 28, 2026
ca28805
fix(repeater): fix contact info backspace and checkmark centering
Avi0n Mar 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ playground.xcworkspace

build/
.build/
.spm-cache/

# CocoaPods
#
Expand Down
40 changes: 40 additions & 0 deletions MC1/Calculations/RFCalculator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,46 @@ struct PathAnalysisResult: Equatable {
let refractionK: Double

var distanceKm: Double { distanceMeters / 1000 }

var worstObstructionPoint: ObstructionPoint? {
obstructionPoints.min(by: { $0.fresnelClearancePercent < $1.fresnelClearancePercent })
}

/// Returns the worst obstruction point per contiguous obstructed region.
/// Groups adjacent obstruction points by sample spacing, then picks the
/// lowest clearance point from each group — one per red bar in the terrain profile.
var peakObstructionPerRegion: [ObstructionPoint] {
guard obstructionPoints.count >= 2 else { return obstructionPoints }

// Find the smallest gap between consecutive points (= one sample step)
var minGap = Double.infinity
for i in 1..<obstructionPoints.count {
let gap = obstructionPoints[i].distanceFromAMeters - obstructionPoints[i - 1].distanceFromAMeters
if gap > 0 && gap < minGap { minGap = gap }
}
guard minGap.isFinite else { return [obstructionPoints[0]] }

// A gap > 2x the sample step means a non-obstructed sample separates two regions
let gapThreshold = minGap * 2.5

var regions: [ObstructionPoint] = []
var regionWorst = obstructionPoints[0]

for i in 1..<obstructionPoints.count {
let point = obstructionPoints[i]
let gap = point.distanceFromAMeters - obstructionPoints[i - 1].distanceFromAMeters

if gap > gapThreshold {
regions.append(regionWorst)
regionWorst = point
} else if point.fresnelClearancePercent < regionWorst.fresnelClearancePercent {
regionWorst = point
}
}
regions.append(regionWorst)

return regions
}
}

/// Elevation sample along the path
Expand Down
4 changes: 4 additions & 0 deletions MC1/Extensions/BatteryInfo+Display.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import SwiftUI
/// Consolidates LiPo voltage-to-percentage calculation previously duplicated in
/// BLEStatusIndicatorView and DeviceInfoView.
extension BatteryInfo {
/// Whether this reading represents a real battery.
/// 0mV indicates no battery hardware (e.g., mains-powered device with no ADC pin).
var isBatteryPresent: Bool { level > 0 }

/// Battery voltage in volts (converted from millivolts)
var voltage: Double {
Double(level) / 1000.0
Expand Down
46 changes: 46 additions & 0 deletions MC1/Extensions/CLLocationCoordinate2D+BoundingRegion.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import CoreLocation
import MapKit
import MapLibre

extension Array where Element == CLLocationCoordinate2D {
/// Computes a bounding `MKCoordinateRegion` that fits all coordinates with padding.
func boundingRegion(paddingMultiplier: Double = 1.5) -> MKCoordinateRegion? {
guard let first else { return nil }

var minLat = first.latitude, maxLat = first.latitude
var minLon = first.longitude, maxLon = first.longitude

for coord in dropFirst() {
minLat = Swift.min(minLat, coord.latitude)
maxLat = Swift.max(maxLat, coord.latitude)
minLon = Swift.min(minLon, coord.longitude)
maxLon = Swift.max(maxLon, coord.longitude)
}

return MKCoordinateRegion(
center: CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
),
span: MKCoordinateSpan(
latitudeDelta: Swift.min(180, Swift.max(0.01, (maxLat - minLat) * paddingMultiplier)),
longitudeDelta: Swift.min(360, Swift.max(0.01, (maxLon - minLon) * paddingMultiplier))
)
)
}
}

extension MKCoordinateRegion {
func toMLNCoordinateBounds() -> MLNCoordinateBounds {
MLNCoordinateBounds(
sw: CLLocationCoordinate2D(
latitude: center.latitude - span.latitudeDelta / 2,
longitude: center.longitude - span.longitudeDelta / 2
),
ne: CLLocationCoordinate2D(
latitude: center.latitude + span.latitudeDelta / 2,
longitude: center.longitude + span.longitudeDelta / 2
)
)
}
}
7 changes: 7 additions & 0 deletions MC1/Extensions/CLLocationCoordinate2D+Formatting.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import CoreLocation

extension CLLocationCoordinate2D {
var formattedString: String {
"\(latitude.formatted(.number.precision(.fractionLength(6)))), \(longitude.formatted(.number.precision(.fractionLength(6))))"
}
}
8 changes: 8 additions & 0 deletions MC1/Extensions/ContactDTO+Coordinate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import CoreLocation
import MC1Services

extension ContactDTO {
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
}
28 changes: 28 additions & 0 deletions MC1/Extensions/ContactType+Display.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import MeshCore
import SwiftUI

extension ContactType {
var iconSystemName: String {
switch self {
case .chat: "person.fill"
case .repeater: "antenna.radiowaves.left.and.right"
case .room: "person.3.fill"
}
}

var displayColor: Color {
switch self {
case .chat: .blue
case .repeater: .green
case .room: .purple
}
}

var pinStyle: MapPoint.PinStyle {
switch self {
case .chat: .contactChat
case .repeater: .contactRepeater
case .room: .contactRoom
}
}
}
28 changes: 25 additions & 3 deletions MC1/Extensions/SNRQuality+Color.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,36 @@
import MC1Services
import SwiftUI
import UIKit

extension SNRQuality {
/// SwiftUI color for signal quality indicators.
var color: Color {
switch self {
case .excellent: .green
case .good: .yellow
case .fair, .poor, .veryPoor: .red
case .excellent, .good: .green
case .fair: .yellow
case .poor: .red
case .unknown: .secondary
}
}

/// UIKit color for MapKit renderers.
var uiColor: UIColor {
switch self {
case .excellent, .good: .systemGreen
case .fair: .systemYellow
case .poor: .systemRed
case .unknown: .systemGray
}
}

/// Localized display label for signal quality.
var localizedLabel: String {
switch self {
case .excellent: L10n.Chats.Chats.Signal.excellent
case .good: L10n.Chats.Chats.Signal.good
case .fair: L10n.Chats.Chats.Signal.fair
case .poor: L10n.Chats.Chats.Signal.poor
case .unknown: L10n.Chats.Chats.Path.Hop.signalUnknown
}
}
}
10 changes: 10 additions & 0 deletions MC1/Extensions/View+LiquidGlass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ extension View {
}
}

/// Applies glass button style on iOS 26+, falls back to bordered (secondary weight) on earlier versions
@ViewBuilder
func liquidGlassSecondaryButtonStyle() -> some View {
if #available(iOS 26.0, *) {
self.buttonStyle(.glass)
} else {
self.buttonStyle(.bordered)
}
}

/// Applies prominent glass button style with tint on iOS 26+, falls back to borderedProminent on earlier versions
@ViewBuilder
func liquidGlassProminentButtonStyle() -> some View {
Expand Down
59 changes: 55 additions & 4 deletions MC1/MC1App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ private let logger = Logger(subsystem: "com.mc1", category: "MC1App")
@main
struct MC1App: App {
@State private var appState: AppState
@State private var awaitingDataProtection = false
@Environment(\.scenePhase) private var scenePhase

#if DEBUG
Expand All @@ -23,13 +24,32 @@ struct MC1App: App {
do {
container = try PersistenceStore.createContainer()
} catch {
logger.fault("Container creation failed, retrying: \(error)")
logger.error("Container creation failed: \(error)")

if UIApplication.shared.isProtectedDataAvailable {
// Data is accessible — this is a genuine failure, not BFU.
// Retry once for transient file system issues.
logger.info("Retrying container creation")
do {
container = try PersistenceStore.createContainer()
} catch {
logger.fault("Container creation failed after retry: \(error)")
fatalError("ModelContainer creation failed after retry while data is available")
}
_appState = State(initialValue: AppState(modelContainer: container))
return
}

// Before first unlock: the encrypted store is inaccessible. Create a throwaway
// in-memory container so the struct can initialize. The .task body will wait for
// data protection and replace this with the real store before doing any work.
logger.warning("Protected data unavailable (before first unlock), deferring initialization")
do {
container = try PersistenceStore.createContainer()
container = try PersistenceStore.createContainer(inMemory: true)
} catch {
logger.fault("Container creation failed after retry: \(error)")
fatalError("Unrecoverable: ModelContainer creation failed after retry")
fatalError("In-memory ModelContainer creation failed: \(error)")
}
_awaitingDataProtection = State(initialValue: true)
}
_appState = State(initialValue: AppState(modelContainer: container))
}
Expand All @@ -39,6 +59,18 @@ struct MC1App: App {
ContentView()
.environment(\.appState, appState)
.task {
if awaitingDataProtection {
await waitForProtectedData()
do {
let container = try PersistenceStore.createContainer()
appState = AppState(modelContainer: container)
awaitingDataProtection = false
} catch {
logger.fault("Container creation failed after unlock: \(error)")
fatalError("ModelContainer creation failed after protected data became available")
}
}

try? Tips.configure([
.displayFrequency(.immediate)
])
Expand Down Expand Up @@ -90,6 +122,25 @@ struct MC1App: App {
}
#endif

private func waitForProtectedData() async {
guard !UIApplication.shared.isProtectedDataAvailable else { return }
let notification = UIApplication.protectedDataDidBecomeAvailableNotification
await withTaskGroup(of: Void.self) { group in
group.addTask {
for await _ in NotificationCenter.default.notifications(named: notification) {
return
}
}
group.addTask {
while await !UIApplication.shared.isProtectedDataAvailable {
try? await Task.sleep(for: .seconds(1))
}
}
await group.next()
group.cancelAll()
}
}

private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) {
switch newPhase {
case .active:
Expand Down
Loading