Skip to content

Acquisition-readiness sweep: security, dead code, duplicates, UI/UX, structure#53

Open
jfuginay wants to merge 31 commits into
release/0.35from
fix/acquisition-audit-ios
Open

Acquisition-readiness sweep: security, dead code, duplicates, UI/UX, structure#53
jfuginay wants to merge 31 commits into
release/0.35from
fix/acquisition-audit-ios

Conversation

@jfuginay

Copy link
Copy Markdown
Contributor

Five-stage sweep driven by the acquisition audit (security → dead code → duplicates → UI/UX → structure/tests/CI). Full app build green at every stage; the suite now runs 176 unit tests with 0 failures.

Security

  • e0da6dd fix(security): move Cesium Ion token out of source into gitignored xcconfig
  • 0b1e4e3 fix(security): stop blind-accepting TLS certs on the no-CA stream path
  • ef636ec fix(security): replace global NSAllowsArbitraryLoads with scoped ATS exceptions
  • 8dff0c0 refactor(security): one TAKTLSSessionDelegate replaces four trust-bypass delegates
  • 1df9f22 refactor(security): single PKCS12 import pipeline owns SecPKCS12Import
  • f22bf7f fix(structure): TAKService.init is private — compiler enforces .shared

Dead code

  • 9a505e0 chore: delete 13 orphaned Swift files tracked in no Xcode target
  • 0808879 chore: delete 19 dead compiled files across 5 verified clusters
  • 52551cd chore: delete 15 more zero-reference compiled files (dead views/services)
  • 8a08b52 refactor: remove dead MultiServerFederation stack; repoint status helpers at TAKService

Duplicates / single-source consolidation

  • ce16798 fix: replace fabricated MGRS/UTM readouts with canonical MGRSConverter
  • 22e0cae refactor: single CoTXMLBuilder owns the envelope, escaping, timestamps
  • 9e15044 fix: military reports share one CoT pipeline; CAS/SPOTREP stop masquerading as MEDEVAC
  • 7fe48f9 fix: single Meshtastic node→CoT pipeline via MeshtasticCoTConverter
  • 1ffb832 refactor: delete the unrendered EnhancedCoTMarker store; cotEvents is the single source
  • 3c327ff refactor: single operator-identity source; drop per-service callsign caches
  • 46c8c2e refactor: ConnectionStateSnapshot is the single published connection-status source
  • 10ed700 refactor: single ToolRegistry; ToolSheetHost is the only tool dispatcher
  • e2fcf8a refactor: bearing/distance math delegates to MeasurementCalculator

UI/UX

  • 7fc7208 fix(ui): dead Cancel, dead Name field, Settings i18n, MGRS 2D-only UX
  • 7537dc8 fix(radial): marker Info opens a sheet; Share/Copy give feedback; civilian Save works
  • 8fa3d0b fix(cesium): measurement HUD + live polyline and navigation panel + route on the 3D globe
  • d677d21 fix(settings): wire the Self-position marker picker into both engines
  • 9d7a97a feat(i18n): localize the legacy 5x4 Tools grid via tools.* keys
  • dcee779 fix(plugins): enable/disable toggles gate launcher, toolbar, and radial menu
  • 87d345f fix(tracks,gyb): surface track persistence errors; gate gyb BLE on toggle + auto-reconnect

Structure / tests / CI

  • f0ef378 refactor(map): decompose the 5,678-line MapViewController god file (LocationManager / TacticalMapView / CesiumMainMap / annotations / chrome extracted; Cesium scene HTML is now a bundled resource with runtime __CESIUM_ION_TOKEN__ injection, matching the Android asset convention)
  • 23043b4 refactor(map): engine-agnostic mapChrome — shared overlays mount once (structurally kills the "feature wired into one engine only" bug class; verified in-simulator on both engines)
  • 430d119 test: real OmniTAKMobileTests unit-test target — 176 tests, all green (the 10 orphaned XCTest files now compile and run; revived suite immediately caught + fixed a real bug: bare IPv6 hosts were always rejected by ServerValidator)
  • efe1379 ci: GitHub Actions build + test on macos-15 (pushes to main / release/*, all PRs; SPM cached; no secrets required)

Action items for J

  • Rotate the Cesium Ion token — the old token was hardcoded in two source files of this public repo (and ships in released binaries). The code now reads it from gitignored Config.xcconfig → Info.plist, but the exposed token itself must be revoked/reissued at https://ion.cesium.com/tokens.
  • Rotate the Mapbox token (lower priority) — the pk. token is recoverable from git history (commit 2481406) despite the xcconfig migration on HEAD. Rotate + bundle-id-restrict in the Mapbox console.

Verification

  • xcodebuild build — green
  • xcodebuild test (OmniTAKMobileTests, iPhone 17 Pro sim) — 176 tests, 0 failures
  • Simulator smoke on both map engines: chrome renders over the Cesium globe and the Mapbox map; bundled cesium_scene.html loads with token injection

🤖 Generated with Claude Code

jfuginay and others added 30 commits June 9, 2026 19:41
Toolbar cells, add-palette rows, coachmark, edit hints, and Done/Add
buttons now resolve through LocalizationManager (bar.* keys). Adds the
37-key bar.* block to es/fr/de/pl/uk catalogs (en/zh-Hant already had
them), terminology-matched to each catalog's existing vocabulary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…config

The Cesium Ion JWT was hardcoded verbatim in two Swift files
(MapViewController.swift, ATAKToolsView.swift) of a public repo. Migrate
it to the same gitignored-xcconfig pattern already used for the Mapbox
token: CESIUM_ION_TOKEN in Config.xcconfig -> Info.plist $(CESIUM_ION_TOKEN)
substitution -> single runtime read via CesiumIonConfig (Bundle.main).
Both former literals now alias the one config source. An empty token
still builds and runs; only Ion-hosted world terrain is skipped.

The old token remains in public git history and must be rotated at
cesium.com.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DirectTCPSender's no-CA branch installed a verify block that
unconditionally complete(true)d every server certificate, silently
exposing those sessions to MITM. Now:

- Default (no CA, no opt-in): proper SecTrustEvaluateWithError against
  the system root store.
- New TAKServer.allowUntrustedTLS (default false; custom init(from:)
  keeps previously-saved servers decoding) gates the old accept-any
  behavior behind an explicit per-server opt-in, surfaced as a
  "Trust Untrusted Certificates" toggle in the server edit form under a
  new Advanced area with a clear MITM warning, localized via
  LocalizationManager in all 7 catalogs (en/es/fr/de/pl/uk/zh-Hant).
- Post-enrollment connects (SimpleEnrollView, DeepLinkHandler token
  flow, ConnectionStatusWidget reconnect) now pass the enrolled CA
  truststore so they validate via the pinned-CA path instead of
  falling into the no-CA branch; the token-enrollment flow also pins
  caCertificateName to the "alias-ca" chain it already stores, same as
  the CSR flow.

Self-signed servers without a truststore now require the explicit
toggle instead of being trusted silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…exceptions

ATS was globally disabled. Inventory of actual cleartext consumers:
TAK streams use NWConnection and RTSP/SRT video uses VLCKit sockets
(neither is ATS-governed); enrollment, Marti REST, Cesium CDN and tile
downloads are all https; the only http consumers in the URL loading
system are the local MBTiles server on 127.0.0.1 and user-entered
HTTP/HLS video feeds played by AVPlayer.

Tightest working config: drop NSAllowsArbitraryLoads, keep
NSAllowsLocalNetworking + localhost/127.0.0.1 exception domains, and
add NSAllowsArbitraryLoadsForMedia so field video feeds (routinely
plain http) keep playing — the exemption is scoped to AVFoundation
media loads only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
These files (5,098 LOC) have zero entries in project.pbxproj — the project
uses explicit PBXFileReference entries with no file-system-synchronized
groups, so they have never compiled into any target — and zero inbound
references from any source file (re-verified by word-boundary type grep
before deletion). They include superseded duplicates of live code:
TAKMissionSyncManager/TAKDataPackageService (replaced by MissionSyncManager
+ TAKRestAPIClient) and CoordinateConverter (a wrong-math MGRS duplicate of
the canonical MGRSConverter). Git history preserves them if ever needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Each cluster was adversarially verified to have zero inbound references
(word-boundary grep re-run before deletion); all were compiled into the
shipping binary as pure bloat:

- CoT-filter UI (5): CoTFilterPanel, CoTUnitListView, CoTFilterManager,
  CoTFilterCriteria, CoTFilterModel — built-but-never-integrated feature,
  absent from ToolSheetHost/ToolsLauncherSheet routing
- Dead chat backend (3): ChatService, ChatCoTGenerator (incl. private
  GeoChatXMLParser), ChatStorageManager — superseded by ChatManager +
  ChatPersistence + ChatXMLGenerator, which every live consumer uses
- Dead measurement cluster (3): MeasurementService, MeasurementOverlay,
  MeasurementButton — live measurement is MeasurementManager +
  CompactMeasurementOverlay; also corrected the stale comment at
  MapViewController.swift:4324 that claimed MeasurementPointAnnotation
  was in use
- RadialMenu support (4): RadialMenuButton, RadialMenuItemView,
  RadialMenuAnimations, RadialMenuGestureHandler — live radial path is
  RadialMenuView/Models/Presets/ActionExecutor, which stay
- Pre-Cesium MapLibre-3D path (4): MapLibre3DSettingsView, MapLibre3DView,
  Map3DSettingsView, MGRSGridToggleView — verified the live replacements
  first: Settings mgrsGridEnabled toggle + overlayCoordinator-driven
  GridOverlayView, and Cesium as the live 3D engine

pbxproj PBXBuildFile/PBXFileReference/group/Sources entries removed
(76 lines, 4 per file). Build verified: BUILD SUCCEEDED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ces)

From the '12 more compiled files' audit finding (16 itemized files,
verified per-type zero inbound references; re-verified by word-boundary
grep before deletion):

AircraftAnnotation, CertificateSelectionView, CertificateManagementView
(transitively dead via CertificateSelectionView), KMLImportView,
MarkerAnnotationView (legacy MapKit annotation types never registered),
ArcGISTileSource, WaypointListView (incl. WaypointDetailView and its
rogue fresh TAKService() instance), ImprovedErrorDialog, OfflineMapModels
(parallel unused offline-maps model set), UASVideoPipView, DataPackageButton,
TrackRecordingButton, TrackRecordingView (transitively dead via
TrackRecordingButton), VideoStreamButton, KMLMapIntegration.

Deliberately KEPT despite zero references today:
- SelfPositionMarkerImage.swift — the UI/UX audit fix for the dead
  selfMarkerStyle Settings picker wires it into both map engines
- RemoteIdToPointMarkerConverter.swift — staged work on the gyb/RID
  roadmap, not rot

pbxproj entries removed (60 lines, 4 per file). BUILD SUCCEEDED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pers at TAKService

MultiServerFederation duplicated TAKService.shared's native multi-server
support (serverConnections/connectedServerIds/connectToServer) with its
own server list, status enum, CoT XML builder, and per-server fresh
TAKService() instances that bypassed the singleton and would re-run
omnitak_init(). Its addServer was never called, so getConnectedCount()
was always 0: sendSelfPosition could never send (and would have minted a
new SELF-<uuid> map entity per tick if it had), and the status helpers
always reported zero servers.

Changes:
- Delete Utilities/Network/MultiServerFederation.swift (417 LOC) and its
  4 pbxproj entries
- Remove the federation @StateObject from ATAKMapView
- Delete sendSelfPosition (zero callers; live PPLI is
  PositionBroadcastService)
- Repoint multiServerConnectionStatus/multiServerDisplayName at
  takService.connectedServerIds + ServerManager.shared so they read the
  real connection state instead of the always-empty federation

BUILD SUCCEEDED.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CoordinateDisplayView.formatMGRS computed the 100km grid square as a
coordinate hash (abs(Int(lat*10+lon*10)) % 60) and scaled degrees
linearly for easting/northing — plausible-looking but wrong MGRS shown
as the default readout on both map engines. Its formatUTM truncated the
meridian-arc series (up to ~16km northing error). Both now delegate to
MGRSConverter exactly as formatBNG/formatTWD97 already delegate to
their converters; the private getLatitudeBand copy is gone.

The callsign panel's formatCoordinates (MapViewController) returned a
hardcoded '11T MN' grid zone with raw DMS digits glued on, for every
user on earth. It now reads the coordinateDisplayFormat preference and
delegates to CoordinateDisplayFormat.format(_:), which routes through
MGRSConverter/BNGConverter/TWD97Converter.

Operator-facing grid references in a TAK client must be real geodesy —
a wrong-but-plausible MGRS string is a safety issue when relayed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…imestamps

The codebase had no CoT serializer: ~18 files hand-rolled the <event>
envelope with their own ISO8601DateFormatter() (47 instantiations split
between fractional/non-fractional, so timestamp precision differed by
feature) and 13 private escapeXML copies (some missing entities, some
double-escaping).

New CoT/CoTXMLBuilder.swift provides:
- CoTXMLBuilder.buildEvent(uid:type:how:time:start:staleAfter:lat:lon:
  hae:ce:le:detail:) — single owner of the XML declaration, <event>
  attributes, and <point>; generators supply only their <detail> payload
- String.xmlEscaped — the one 5-entity escaper (& first)
- one static ISO8601 formatter pair (fractional canonical for output,
  non-fractional for fallback parsing); ISO8601DateFormatter is
  thread-safe so shared statics are safe

Migrated generators: ChatXMLGenerator (GeoChat), LassoCotBuilders
(delete tombstone + dest-routed rebuild), PositionBroadcastService
(self-SA PPLI), WaypointManager, MarkerCoTGenerator, TeamCoTGenerator
(3 events), GeofenceCoTGenerator (2 events), DigitalPointerService,
EmergencyBeaconService, MeshtasticCoTConverter (2 events — also fixes
a double-escaping bug where pre-escaped names were re-escaped inside
remarks). Parsers (CoTMessageParser, ChatXMLParser, TAKRestAPIClient,
DigitalPointerService) now parse timestamps via the shared pair —
ChatXMLParser previously failed on fractional-seconds timestamps.
GPX/KML exporters (TrackRecordingService, BreadcrumbTrailService) use
the shared formatters and lost their two private escapeXML copies.

CoTEventHandler no longer serializes a parsed event back to XML just to
re-parse it into a ChatParticipant (createPresenceXML round-trip
deleted; the presence XML never carried an endpoint so the direct
mapping is equivalent). Dead zero-caller builders deleted:
MapViewController.generateSelfCoT, ChatXMLGenerator
.generatePresenceWithChatEndpoint/.generateImageGeoChatXML.

Wire format notes: attribute order is uniform now (TAK parsers are
attribute-order agnostic); timestamps are uniformly fractional;
envelopes that previously omitted how= (WaypointManager) now carry
h-g-i-g-o. CAS/MEDEVAC/SPOTREP views and MeshtasticCOTBridge are
consolidated in follow-up commits (report-type fixes and bridge-path
deletion respectively).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rading as MEDEVAC

CASRequestView, MEDEVACRequestView, and SPOTREPView each carried a
line-for-line clone of the same envelope builder and send/haptics
block, and the copy-paste froze MEDEVAC's CoT type b-r-f-h-c into all
three — so a 9-Line CAS request and a SPOTREP rendered and routed as
CASEVAC on receiving TAK clients. Divergence had also crept in:
SPOTREP escaped its fields, CAS/MEDEVAC interpolated user free-text
raw (an & or < in remarks produced malformed CoT on the wire).

New Features/Military/Services/MilitaryReportCoT.swift:
- buildReportEvent(uid:type:senderCallsign:lat:lon:reportDetail:
  remarks:) — one envelope (h-g-i-g-o, 1h stale) on CoTXMLBuilder,
  contact + remarks escaped centrally
- sendReport(xml:logTag:) — the shared TAKService send + os.Logger +
  haptic block; views keep only their alert-state toggles

CoT type assignments (documented in MilitaryReportCoT.ReportType):
- MEDEVAC: b-r-f-h-c — unchanged; standard casevac request type
  (ATAK Medline; this repo's federation layer mapped the b-r-f-h-c
  prefix to casevac)
- 9-Line CAS: a-h-G — ATAK's fires workflow attaches the nine-line to
  the hostile target marker rather than emitting a bespoke request
  type, so the request plots a hostile ground target at the target
  coordinates with the <cas> detail riding along
  (MarkerCoTGenerator.CoTTypes hostile family)
- SPOTREP: b-m-p-w — the codebase's own CoTTypes.spotReport constant;
  receivers (including our CoTMessageParser, which maps b-m-p-w to
  waypoint) render a map point at the observed location with the
  <spotrep> detail attached

All report fields are now escaped via String.xmlEscaped (numeric
fields interpolate directly).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ass delegates

Four NSObject URLSessionDelegate classes each independently implemented
accept-any-server-trust (TAKAPIURLSessionDelegate in TAKRestAPIClient,
CSRSelfSignedCertificateDelegate, DeepLinkHandler's SelfSignedCertDelegate,
CertificateEnrollmentService's non-private SelfSignedCertificateDelegate).
Security-critical duplicated code: hardening had to be re-implemented
four times and the copies had already drifted.

New Networking/Services/TAKTLSSessionDelegate(trustMode:certificateId:
certificateName:) generalizes the most capable copy (the REST one, which
also resolves the mTLS client identity) with an explicit trust policy:
- .anchored([SecCertificate]) — chain validation against CA anchors +
  system roots (basic X509, mirroring the streaming path)
- .acceptUntrusted — explicit opt-in only
- .system — default system evaluation

Wiring (now respects the per-server allowUntrustedTLS flag from the
earlier security commit):
- TAKRestAPIClient: TAKAPIConfiguration(from:) derives trustMode with
  the same precedence as the streaming connect path — CA truststore >
  allowUntrustedTLS opt-in > system roots. Previously Marti REST
  accepted ANY certificate unconditionally; now a server that streams
  via its CA truststore gets the same anchored validation on REST.
  DirectTCPSender.loadCACertificates became static internal so both
  paths share the keychain truststore loader.
- CSREnrollmentService: trustSelfSignedCerts flag maps to
  .acceptUntrusted/.system (unchanged behavior, shared code)
- DeepLinkHandler + CertificateEnrollmentService: .acceptUntrusted —
  enrollment bootstraps the truststore from the server itself, so the
  bypass is explicit and documented at the construction site

All four duplicate delegate classes deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SecPKCS12Import was invoked in five files (CertificateImportPipeline x2,
CertificateManager, CertificateEnrollmentService, DataPackageImportManager,
TAKService), each with its own options dict, keychain-write and error
conventions. They had demonstrably diverged: only DataPackageImportManager
had the SecTrust fallback for truststores where kSecImportItemCertChain
isn't populated; only the enrollment path set
kSecAttrAccessibleAfterFirstUnlock on identities; chain label schemes
differed (-cert-N vs -chain-N vs first-unsuffixed) while the CA loader
looks up by exact label.

CertificateImportPipeline is now the only file that calls
SecPKCS12Import:
- static parsePKCS12(_:password:) — the one import invocation, with
  errSecAuthFailed/errSecDecode diagnostics
- static parseIdentity(_:password:) — parse-only helper for the mTLS
  load paths (no keychain writes)
- directImport unified to the superset of the five conventions:
  delete-before-add, kSecAttrAccessibleAfterFirstUnlock on both
  identities and certs, SecTrust truststore fallback, and the
  first-cert-at-bare-label / label-N chain scheme that exact-label
  lookups (DirectTCPSender.loadCACertificates) depend on
- removed a debug print that logged the cert password in plaintext

Migrated callers:
- CertificateManager.extractIdentity → parseIdentity
- TAKService.loadP12Identity → parseIdentity
- DataPackageImportManager.importP12Certificate → pipeline (labels
  unchanged: that scheme is now the pipeline's canonical one)
- CertificateEnrollmentService.importP12Certificate → pipeline; the
  import chain became properly async (no semaphore bridging). CA chain
  labels move from alias-ca-chain-N to alias-ca/alias-ca-N — nothing
  looked up the -chain-N labels, and the bare-label form is what the
  CA loader's exact match expects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two pipelines ran simultaneously on the same $meshNodes data and gave
the same physical radio two identities: MeshtasticCOTBridge (always-on
from MeshtasticManager.init) published uid MESHTASTIC-%08X uppercase,
type a-f-G-U-C (friendly), ce/le=10 through CoTEventHandler; while
enableAutoMapUpdates published uid mesh-%08x lowercase, type a-n-G-U-C
(neutral), ce/le=50 via MeshtasticCoTConverter into the unrendered
enhancedMarkers store. Mesh strangers rendered friendly on the map
because the bridge's (wrong) affiliation was the one that landed in
the rendered store.

MeshtasticCoTConverter is now the single node→CoTEvent mapper (it owns
takUID and the self-node friendly/neutral distinction);
publishMeshNodesToMap/publishNodeToMap route its events through
CoTEventHandler.shared.handle(.positionUpdate) — the same path inbound
CoT takes — so they land in TAKService.cotEvents and render with the
converter's deliberate neutral affiliation for non-self nodes.

MeshtasticCOTBridge.swift deleted entirely: its only wiring was
MeshtasticManager.configureCOTBridge, and with the duplicate mapping
path gone nothing else in the file (duplicate XML renderer, statistics,
its own $meshNodes subscription) had a caller.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the single source

TAKService published two marker stores: cotEvents (rendered by both map
engines via MapViewController.cotMarkers) and enhancedMarkers (a richer
UID-keyed dict with position history that NO view rendered).
CoTEventHandler wrote both on every position update; writers that
touched only enhancedMarkers (the old Meshtastic publish path) silently
never appeared on the map, and any future divergence desynced the
stores. enhancedMarkers' only consumers were BloodhoundService (which
also subscribes to cotEvents and builds its own position history) and
the never-assigned onMarkerUpdated callback.

Deleted:
- TAKService.enhancedMarkers + updateEnhancedMarker/getMarker/
  getAllMarkers/removeStaleMarkers/shouldAddToHistory + history config
  + onMarkerUpdated
- CoTEventHandler's dual writes (position + waypoint handlers) and the
  removeEvent dual delete
- BloodhoundService's $enhancedMarkers subscription,
  processEnhancedMarkers, and the EnhancedCoTMarker updateTrack
  overload (the cotEvents subscription already feeds the same tracks)
- EnhancedCoTMarker.swift (type + UnitAffiliation/UnitType/CoTPosition,
  all unused elsewhere), plus the dead UI that depended on it:
  CustomMarkerAnnotation.swift, MarkerInfoPanel's marker panel

Live shared helpers that were co-located with the dead UI (InfoRow,
cornerRadius(_:corners:)/RoundedCorner, Color.uiColor) moved to
Shared/UI/Components/InfoRow.swift — they're used by
EmergencyBeaconView, DigitalPointerView, ConnectionStatusWidget,
RoutePlanningView, and MapViewController.

MeshtasticManager.clearMeshMarkersFromMap now actually works: it
removes mesh markers via CoTEventHandler.removeEvent instead of
printing that markers 'will expire' against a store the map never read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…caches

The operator callsign lived in five+ places with two different
defaults: PositionBroadcastService (the persisted source, 'ALPHA-1'),
SettingsView/MapViewController @AppStorage (coherent only by key
coincidence), ChatManager's Combine mirror, and GeofenceService /
DigitalPointerService / EmergencyBeaconService / TeamService each
defaulting to 'OmniTAK-iOS' — settable only via configure/updateCallsign
calls that nothing ever made, so those services would stamp the wrong
identity into CoT the moment their send paths get wired.

PositionBroadcastService.shared is now the single source:
- GeofenceService userId/userCallsign, DigitalPointerService and
  EmergencyBeaconService userCallsign became computed properties that
  read the source at XML-build time — cached copies, 'OmniTAK-iOS'
  defaults, and the dead configure(callsign:)/updateCallsign plumbing
  deleted (zero callers existed)
- TeamService.currentUserCallsign seeds from the source and tracks it
  via Combine, routed through its own updateCallsign so the current
  team's member entry stays in sync

ChatManager's existing Combine mirror of the same source and the
view-layer @AppStorage bindings (same key, same default) are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…status source

TAKService published four parallel projections of connection status —
connectionStatus: String, isConnected: Bool, connectionState:
ConnectionStateSnapshot, connectedServerIds: Set<UUID> — which had to
be written in lockstep at every transition site. The codebase already
carried evidence of the desync hazard (sendCoT's 'cached state can get
out of sync' comment, a DEBUG desync detector, and a resync sweep).

Now connectionState is the only @published source:
- isConnected and connectionStatus are computed off the snapshot
- connectedServerIds is derived from serverConnections under the lock
  (same freshness as before — the stored set was also only refreshed
  by updateOverallConnectionState's sync sweep)
- all transition sites (connect/connected/failed/disconnect/
  updateOverallConnectionState) write exactly one snapshot; the
  multi-server 'Connected to X / Connected to N servers' strings ride
  in snapshot.status
- .disconnected factory status aligned to the previously-displayed
  'Disconnected' (was 'Not Connected')

NetworkStatusBar's mirrored @State and WaypointDetailView's fresh
TAKService() instance named in the finding were already gone — both
files were deleted in the earlier dead-code stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The tool catalog was encoded three-plus times keyed by matching string
ids with zero enforcement: ATAKTool.allTools plus ATAKToolsView's own
28-case switch (and 28 @State sheet flags + 28 .sheet modifiers),
ToolsLauncherSheet's row arrays, ToolSheetHost's switch, and
ToolbarCustomization.toolShortcuts (whose own comment admitted each id
'MUST be present in ToolSheetHost' with nothing checking). Drift had
already happened: the fallback grid couldn't reach UAS Connect or Go to
Coordinate at all.

New Core/App/ToolRegistry.swift: ToolDescriptor(id:displayName:icon:
description:destination:) — one catalog of every sheet-presented tool,
destinations as view closures (a dismiss handle for the two that drive
their own dismissal: data packages, point dropper).

- ToolSheetHost.sheetView is now a registry lookup — the single
  dispatcher for the launcher, the customizable toolbar, AND the grid
- ATAKToolsView: the parallel switch, 28 sheet states, and duplicate
  catalog deleted; the grid derives its entries from the registry (its
  three map-mode commands — drawing/measure/lasso — stay grid-local
  since they drive map state, not sheets) and opens tools by posting
  the same .openToolSheet notification the launcher uses. The grid now
  reaches uas/gotocoord, closing the drift.
- TrackRecordingService: one shared instance on the registry replaces
  the separate instances the grid and host each newed up
- ToolsLauncherSheet and the toolbar catalog keep their curated row
  presentation but are validated against the registry in DEBUG builds
  (assertionFailure on any id the registry can't dispatch)

Adding a tool is now one registry entry plus optional surface styling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The same great-circle initial-bearing formula (atan2(y,x) + 0-360
normalization) was re-implemented in ~14 places; magnetic-declination
or back-azimuth refinements added to one copy could never reach the
others. MeasurementCalculator (static, dependency-free) already carried
the canonical bearing(from:to:), distance(from:to:), and
haversineDistance(from:to:).

Delegated the live copies, keeping each call site's local function name
so the change is purely an implementation swap:
- RangeBearingService (both copies: the measurement init and
  calculateTrueBearing/calculateDistance; its magnetic-declination
  layer now builds on the canonical true bearing)
- LineOfSightService (calculateBearing + its hand-rolled haversine)
- TurnByTurnNavigationService, NavigationService (bearing + distance)
- MapLibreService.bearingBetween, TerrainVisualizationService,
  MapStateManager.RangeBearingState, BloodhoundTrack

Not touched: MapViewController's haversineMetres (documented JS-parity
requirement with the embedded Cesium code) and BloodhoundTrack's
forward-projection geodesic (a destination projection, not a bearing
copy). The byte-identical CoTFilterManager/CoTFilterModel pair and the
dead CoordinateConverter named in the finding were already removed by
the earlier dead-code stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Four UI/UX dead-ends from the acquisition audit:

- SimpleEnrollView: the big ATAK-style gray Cancel button next to OK
  was a placeholder no-op ({ /* Cancel action */ }) on the first-run
  onboarding path. It now calls dismiss(), matching the toolbar Cancel.

- Settings "Name" field removed. @AppStorage("userName") persisted a
  value nothing reads — CoT self events, chat, and PLI all key off
  userCallsign, which is the operator identity. The settings.name key
  is dropped from all 7 catalogs.

- Settings hardcoded English routed through LocalizationManager:
  Toolbar section, Customize Toolbar / Build your own, server
  Connected / "%d servers", External gyb Detector + description,
  Connect gyb Detector, Connected, MISSION section, Create new
  mission. New settings.* keys added to all 7 language catalogs.

- MGRS grid is 2D-engine-only but its toggles weren't gated or
  annotated: the Settings row and the Overlays panel now carry a
  localized "2D map only" caption, and enabling the grid while the
  Cesium globe is active auto-switches to the 2D map (prompt-free,
  same precedent as kmlZoomToOverlay).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ilian Save works

Three radial-menu dead taps from the audit, all on live marker/map
surfaces reachable on BOTH engines:

- Marker "Info": executeMarkerInfo posted .radialMenuShowMarkerInfo
  with zero observers (the old MarkerInfoPanel consumer was deleted
  with the dead EnhancedCoTMarker store). New MarkerRadialFeedback
  ViewModifier attaches at ATAKMapView.body level — covering Cesium 3D
  and Mapbox 2D — and presents the new MarkerInfoSheet as a modal
  sheet (name, affiliation, CoT type, canonical MGRS via
  MGRSConverter, lat/lon, altitude, created, remarks).

- Marker "Share": clipboard-only with an unobserved confirmation
  notification — to the user, identical to a dead tap. The modifier
  now presents the system share sheet (UIActivityViewController via
  the existing LassoShareSheet wrapper) with the share text.

- "Copy" / "Get info": pasteboard writes had no visual confirmation.
  The modifier shows a transient "Copied" HUD capsule (same pattern as
  the Cesium load-fallback note), localized in all 7 catalogs.

- Civilian-mode "Save" (save_location): fell through to the generic
  .radialMenuCustomAction post whose only observer handles
  draw_shape/meshtastic. Now drops a neutral favorite marker
  (heart icon, SAVED-HHmm) through PointDropperService, the same
  path executeAddWaypoint uses.

New keys (radial.copied, markerInfo.*) added to all 7 language
catalogs. New views appended to MapContextMenus.swift because the
pbxproj uses explicit file references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oute on the 3D globe

Two dual-engine parity gaps on the DEFAULT (Cesium) engine:

- Measurement: CompactMeasurementOverlay was mounted only in
  mapbox2DBody, while measurement triggers (radial measure actions,
  Tools grid, toolbar command) fire on both engines. On the globe the
  tool invisibly activated, swallowed every map tap into
  measurementManager, and offered no way to finish or cancel. The HUD
  is now shared chrome (measurementChrome) mounted on BOTH bodies, and
  the in-progress polyline bridges through CesiumMainMap as a
  systemYellow "meas-live" measurement with per-segment distance
  labels — same schema as the saved sessions already mirrored.

- Navigation: RouteNavigationPanel and the route geometry were 2D-only
  (routeOverlayCoordinator renders via MKOverlay, which the WebView
  can't show). The panel is now shared chrome (routeNavigationChrome)
  on both bodies, and the active route bridges through CesiumMainMap:
  polyline in the route's color (calculated segment paths, falling
  back to straight waypoint legs) plus labelled waypoint billboards.

"route-" joins the non-contact uid prefixes so tapping route geometry
on the globe doesn't open the marker-context menu. Stale "stays
Mapbox-only" comment corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Settings "Self-position marker" picker persisted selfMarkerStyle
but nothing read it — its intended consumer SelfPositionMarkerImage
lost its caller in the MapKit→Mapbox migration (the old MKUserLocation
delegate consumed it; the Mapbox path hardcoded .puck2D()).

- Mapbox 2D: the location puck now renders
  SelfPositionMarkerImage.milStdFriendlyCombat / .bullseye via
  Puck2DConfiguration(topImage:) (TacticalMapView.selfPuckType()),
  re-applied live in updateUIView when the persisted style changes.

- Cesium 3D: CesiumMainMap takes selfMarkerStyle; "bullseye"
  suppresses the self entity's SFGPUCI---- SIDC so the HTML falls
  back to the friendly green disc + center-dot canvas billboard —
  the globe's bullseye analog. "milstd" keeps the milsymbol frame.

Stale SettingsView comment pointing at the long-gone MKUserLocation
handler corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The still-reachable fallback grid ("Full Tools…" passthrough) rendered
ToolRegistry's hardcoded English displayNames next to the fully
localized ToolsLauncherSheet — a zh-Hant user tapping Full Tools
dropped into an all-English screen.

- ToolButton now renders loc.t("tools.<id>.title") (the same keys the
  launcher uses) with the subtitle as the accessibility hint; the
  header "Tools", "Show disabled" toggle, and "BETA" badge route
  through new tools.grid.* keys.
- New keys for the 12 grid-only tool ids (chat, data, video, offline,
  drawing, measure, lasso, bloodhound, meshtastic, echelon, uas,
  settings) added to ALL 7 catalogs, with the current hardcoded
  strings as the en values.
- Side effect: shared ids now use the launcher's curated labels in the
  grid too (e.g. "Emergency" → "Emergency Beacon"), unifying the two
  surfaces on one catalog.

Note: de/es/fr/uk/pl never had the 19 launcher tools.* keys (i18n v1
scoped en + zh-Hant for tools); those languages keep falling back to
English for shared ids, unchanged. All NEW keys ship in all 7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…al menu

PluginSettingsManager.isToolEnabled was consumed in exactly one place —
the legacy 5x4 grid — so "disabling" a feature in Settings → Plugins
appeared to work while the tool stayed one tap away on every modern
surface. Now:

- ToolsLauncherSheet (the primary tools surface) filters its sectioned
  catalog through isToolEnabled; sections left empty are dropped.
- ToolbarConfigStore filters both the rendered bar items and the
  add-shortcut palette (openTool shortcuts, plus the measure/drawing
  map commands which map to the measure/drawing plugins). The persisted
  layout keeps the id, so re-enabling a plugin restores the button
  where the operator had it. The store re-publishes on plugin-state
  changes so the bar updates live.
- Radial menus filter items via RadialMenuAction.pluginToolID
  (measure*, drawing actions, navigate/createRoute → routes, quickChat
  → chat, emergency → alert, custom meshtastic) at all three
  configuration choke points (showRadialMenu, showContextMenu, and the
  Mapbox showPointMarkerMenu extension).

Core/non-gated actions (drop marker, copy, edit/delete, layers, mode)
are untouched; settings/plugins/pointer remain always-enabled per
PluginSettingsManager.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ggle + auto-reconnect

Tracks — TrackRecordingService swallowed every persistence/export
failure in print-only catches, so a failed save/delete/export looked
like a silent no-op (and a track could vanish on next launch):
- New @published lastError, set (localized via new tracks.error.* keys
  in all 7 catalogs) in saveTrack, deleteTrack, and the GPX/KML export
  writers.
- TrackListView and TrackDetailView surface it as an alert (detail view
  needs its own — it presents as a sheet over the list). Note: the
  audit's suggested TrackRecordingView no longer exists; it was deleted
  as zero-reference dead code in the earlier sweep, so TrackListView +
  TrackDetailView are the live surfaces.

gyb (audit-gyb findings 3 + 4, iOS side):
- reconnectLast() now respects the Settings "External gyb Detector"
  toggle. GybManager.shared exists from app launch, so the
  unconditional poweredOn reconnect used to re-link the detector and
  plot RID markers with the feature OFF — with no purge timer running
  in that state, so the markers never went stale. handleLine gets a
  belt-and-braces enabled guard for the same reason.
- Auto-reconnect after an unexpected BLE drop: didDisconnectPeripheral
  / didFailToConnect schedule a delayed reconnectLast() with 1s→30s
  exponential backoff, cancelled by user disconnect, toggle off, or a
  successful connect (the firmware already restarts advertising on
  disconnect — the phone just never tried again, killing the stream
  mid-demo until an app relaunch).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mechanical extraction into sibling files in Features/Map/Controllers —
no behavior change:

- LocationManager.swift — the shared CLLocation source
- TacticalMapView.swift — 2D Mapbox representable + coordinator + the
  radial-menu Mapbox bridge
- CesiumMainMap.swift — 3D Cesium WKWebView representable + JS bridge
- MapDrawingAnnotations.swift — MKAnnotation classes for drawing shapes
- ATAKMapChrome.swift — ATAKStatusBar / ATAKBottomToolbar / ATAKSidePanel /
  OverlaySettingsPanel + button helpers

The embedded ~300-line Cesium scene HTML moves out of the Swift string
literal into a bundled resource (OmniTAKMobile/Resources/cesium_scene.html,
same __CESIUM_ION_TOKEN__ placeholder convention as the Android asset);
CesiumMainMap.html now loads it at runtime and injects the Ion token from
CesiumIonConfig (Config.xcconfig → Info.plist), preserving the
never-hardcode-the-token invariant from the security pass.

ATAKMapView and its body stay in MapViewController.swift (now 2,208
lines). This was the single largest maintainability/bus-factor item in
the acquisition audit: one file held both map engines, the GPS source,
the chrome, and the scene HTML.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ATAKMapView.body now composes three layers in one ZStack:

- the engine switch renders ONLY the bare engine (cesiumEngineView /
  mapboxEngineView)
- engine-specific overlays stay conditional (2D-only: loading splash,
  cursor mode; the MGRS gridOverlay stays inside mapboxEngineView)
- ONE mapChrome @ViewBuilder owns every overlay shared by both engines:
  point-drop crosshair, toolbars, side panels, status indicators, map
  overlay components, radial menu, GPS-follow, lasso pill, measurement
  HUD, route navigation panel

interactiveOverlays is dissolved into mapChrome + the 2D conditional.
Explicit zIndexes (crosshair 900 … radial 3000) all compete in the same
ZStack, preserving the previous stacking.

Why: chrome chained onto one engine's ZStack is the recurring bug class
here — radial menu, side panels, measurement HUD, and GPS-follow each
shipped broken on the other engine at some point. With one mount point
a new overlay can no longer be wired into a single engine by accident.

Verified in the simulator on both engines (chrome renders over Cesium
globe and over the Mapbox Standard map).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A fresh TAKService() re-runs omnitak_init(), re-wires the CoT event
handler, registers duplicate app-lifecycle observers, and creates
competing connection/PPLI state divorced from the .shared instance the
UI observes. The audit found live screens doing this (WaypointDetailView,
MultiServerFederation — both already deleted on this branch). Making
init private closes the bug class for good; the one remaining caller
(ConnectionStatusWidget's DEBUG preview) now uses .shared.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 10 XCTest files in OmniTAKMobileTests/ (~3,200 LOC of CoT parsing,
TAKService state, ServerValidator, CSR enrollment, mesh codec,
OpenDroneID, PPLI scheduler coverage) were in NO Xcode target: the
project had only app + UITests natives, the scheme's TestAction was
empty, and none of it ever compiled or ran ("a passing build proves
nothing" — acquisition audit).

- add PBXNativeTarget OmniTAKMobileTests (unit-test bundle, TEST_HOST
  the app) via the xcodeproj gem; wire into the scheme's Testables
- fix the suite for this branch's consolidations:
  - delete tests of deleted code (UnitAffiliation/UnitType enums,
    maxHistoryPerUnit/historyRetentionTime — all removed with the
    EnhancedCoTMarker store)
  - rewrite CertificateHandlingTests against the real API
    (CertificateFormatConverter.detectFormat, TAKCertificate,
    CertificateImportPipeline()) — the originals targeted helpers that
    never existed on this codebase
  - update stale assertions to intentional behavior changes
    (ConnectionStateSnapshot status "Disconnected"; scheme/path-prefixed
    hosts now accepted for reverse-proxied servers; 404 wording)
  - gate TAKPacketCodecTests' standalone-script block behind
    -D STANDALONE_REPRODUCER (the old `#if swift(>=5.9)` guard was
    always true and its top-level entry call broke the target build)

And one real bug the revived suite caught immediately:

- fix(validator): bare IPv6 hosts were always rejected — the
  :port-strip heuristic turned "::1" into ":". Only strip when the
  colon is a real host:port delimiter, and unwrap bracketed
  "[::1]:8089". testValidationWithIPv6Address now passes;
  bracketed-form coverage added.

xcodebuild test -scheme OmniTAKMobile: 176 tests, 0 failures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Build-for-testing then test-without-building on an auto-selected iPhone
simulator, for pushes to main / release/* and all PRs. SPM checkouts
cached (keyed on the pbxproj — Package.resolved isn't committed; the
package pins live in XCRemoteSwiftPackageReference). No secrets needed:
Config.base.xcconfig ships empty token defaults so a clean checkout
compiles.

The repo previously had no CI at all — combined with the orphaned test
suite, a green local build was the only signal. Now every PR proves
build + 176 unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant